diff --git a/.core_files.yaml b/.core_files.yaml index 6fd3a74df92..2624c4432be 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -6,6 +6,7 @@ core: &core - homeassistant/helpers/** - homeassistant/package_constraints.txt - homeassistant/util/** + - mypy.ini - pyproject.toml - requirements.txt - setup.cfg @@ -131,6 +132,7 @@ tests: &tests - tests/components/conftest.py - tests/components/diagnostics/** - tests/components/history/** + - tests/components/light/common.py - tests/components/logbook/** - tests/components/recorder/** - tests/components/repairs/** diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index f4e4de97e78..8f419cca1da 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -517,7 +517,7 @@ jobs: tags: ${{ env.HASSFEST_IMAGE_TAG }} - name: Run hassfest against core - run: docker run --rm -v ${{ github.workspace }}/homeassistant:/github/workspace/homeassistant ${{ env.HASSFEST_IMAGE_TAG }} --core-integrations-path=/github/workspace/homeassistant/components + run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4 + uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.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 34c2fa838a6..9d6f207382d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -240,7 +240,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: venv key: >- @@ -256,7 +256,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -286,7 +286,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -295,7 +295,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -326,7 +326,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -335,7 +335,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -366,7 +366,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -375,7 +375,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -482,7 +482,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: venv key: >- @@ -490,7 +490,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -578,7 +578,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -611,7 +611,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -649,7 +649,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -692,7 +692,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -739,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -791,7 +791,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -799,7 +799,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: .mypy_cache key: >- @@ -865,7 +865,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -929,7 +929,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1050,7 +1050,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1179,7 +1179,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1273,7 +1273,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: fail_ci_if_error: true flags: full-suite @@ -1325,7 +1325,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1411,7 +1411,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4977139f5dc..d3efa8ebaa3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.5 + uses: github/codeql-action/init@v3.27.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.5 + uses: github/codeql-action/analyze@v3.27.9 with: category: "/language:python" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 749f95fa922..a36b3073aab 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -197,33 +197,6 @@ jobs: split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - name: Create requirements for cython<3 - if: matrix.abi == 'cp312' - run: | - # Some dependencies still require 'cython<3' - # and don't yet use isolated build environments. - # Build these first. - # pydantic: https://github.com/pydantic/pydantic/issues/7689 - - touch requirements_old-cython.txt - cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt - - - name: Build wheels (old cython) - uses: home-assistant/wheels@2024.11.0 - if: matrix.abi == 'cp312' - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_old-cython.txt" - pip: "'cython<3'" - - name: Build wheels (part 1) uses: home-assistant/wheels@2024.11.0 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9947ee05ad1..6ecae762dcd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.1 + rev: v0.8.3 hooks: - id: ruff args: diff --git a/.strict-typing b/.strict-typing index ed698c26ea0..899b22af35f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -137,6 +137,7 @@ homeassistant.components.co2signal.* homeassistant.components.command_line.* homeassistant.components.config.* homeassistant.components.configurator.* +homeassistant.components.cookidoo.* homeassistant.components.counter.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* @@ -169,6 +170,7 @@ homeassistant.components.easyenergy.* homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* +homeassistant.components.eheimdigital.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* homeassistant.components.elevenlabs.* @@ -269,6 +271,7 @@ homeassistant.components.ios.* homeassistant.components.iotty.* homeassistant.components.ipp.* homeassistant.components.iqvia.* +homeassistant.components.iron_os.* homeassistant.components.islamic_prayer_times.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* @@ -365,6 +368,7 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* +homeassistant.components.powerfox.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* @@ -401,6 +405,7 @@ homeassistant.components.romy.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.rtsp_to_webrtc.* +homeassistant.components.russound_rio.* homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvitag_ble.* homeassistant.components.samsungtv.* @@ -439,7 +444,6 @@ homeassistant.components.ssdp.* homeassistant.components.starlink.* homeassistant.components.statistics.* homeassistant.components.steamist.* -homeassistant.components.stookalert.* homeassistant.components.stookwijzer.* homeassistant.components.stream.* homeassistant.components.streamlabswater.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1f95c5eef8f..7425e7a2533 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,7 +16,7 @@ { "label": "Pytest", "type": "shell", - "command": "python3 -m pytest --timeout=10 tests", + "command": "${command:python.interpreterPath} -m pytest --timeout=10 tests", "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", @@ -31,7 +31,7 @@ { "label": "Pytest (changed tests only)", "type": "shell", - "command": "python3 -m pytest --timeout=10 --picked", + "command": "${command:python.interpreterPath} -m pytest --timeout=10 --picked", "group": { "kind": "test", "isDefault": true @@ -89,7 +89,7 @@ "label": "Code Coverage", "detail": "Generate code coverage report for a given integration.", "type": "shell", - "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", + "command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "dependsOn": ["Compile English translations"], "group": { "kind": "test", @@ -105,7 +105,7 @@ "label": "Update syrupy snapshots", "detail": "Update syrupy snapshots for a given integration.", "type": "shell", - "command": "python3 -m pytest ./tests/components/${input:integrationName} --snapshot-update", + "command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName} --snapshot-update", "dependsOn": ["Compile English translations"], "group": { "kind": "test", @@ -163,7 +163,7 @@ "label": "Compile English translations", "detail": "In order to test changes to translation files, the translation strings must be compiled into Home Assistant's translation directories.", "type": "shell", - "command": "python3 -m script.translations develop --all", + "command": "${command:python.interpreterPath} -m script.translations develop --all", "group": { "kind": "build", "isDefault": true @@ -173,7 +173,7 @@ "label": "Run scaffold", "detail": "Add new functionality to a integration using a scaffold.", "type": "shell", - "command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", + "command": "${command:python.interpreterPath} -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", "group": { "kind": "build", "isDefault": true @@ -183,7 +183,7 @@ "label": "Create new integration", "detail": "Use the scaffold to create a new integration.", "type": "shell", - "command": "python3 -m script.scaffold integration", + "command": "${command:python.interpreterPath} -m script.scaffold integration", "group": { "kind": "build", "isDefault": true diff --git a/CODEOWNERS b/CODEOWNERS index 7755c3eb4ae..f1c6aa4aea5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -284,6 +284,8 @@ build.json @home-assistant/supervisor /tests/components/control4/ @lawtancool /homeassistant/components/conversation/ @home-assistant/core @synesthesiam /tests/components/conversation/ @home-assistant/core @synesthesiam +/homeassistant/components/cookidoo/ @miaucl +/tests/components/cookidoo/ @miaucl /homeassistant/components/coolmaster/ @OnFreund /tests/components/coolmaster/ @OnFreund /homeassistant/components/counter/ @fabaff @@ -385,6 +387,8 @@ build.json @home-assistant/supervisor /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt +/homeassistant/components/eheimdigital/ @autinerd +/tests/components/eheimdigital/ @autinerd /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 @@ -727,8 +731,8 @@ build.json @home-assistant/supervisor /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard /tests/components/iotawatt/ @gtdiehl @jyavenard -/homeassistant/components/iotty/ @pburgio @shapournemati-iotty -/tests/components/iotty/ @pburgio @shapournemati-iotty +/homeassistant/components/iotty/ @shapournemati-iotty +/tests/components/iotty/ @shapournemati-iotty /homeassistant/components/iperf3/ @rohankapoorcom /homeassistant/components/ipma/ @dgomes /tests/components/ipma/ @dgomes @@ -753,6 +757,8 @@ build.json @home-assistant/supervisor /tests/components/ista_ecotrend/ @tr4nt0r /homeassistant/components/isy994/ @bdraco @shbatm /tests/components/isy994/ @bdraco @shbatm +/homeassistant/components/ituran/ @shmuelzon +/tests/components/ituran/ @shmuelzon /homeassistant/components/izone/ @Swamp-Ig /tests/components/izone/ @Swamp-Ig /homeassistant/components/jellyfin/ @j-stienstra @ctalkington @@ -1047,6 +1053,8 @@ build.json @home-assistant/supervisor /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 +/homeassistant/components/ohme/ @dan-r +/tests/components/ohme/ @dan-r /homeassistant/components/ollama/ @synesthesiam /tests/components/ollama/ @synesthesiam /homeassistant/components/ombi/ @larssont @@ -1133,6 +1141,8 @@ build.json @home-assistant/supervisor /tests/components/point/ @fredrike /homeassistant/components/poolsense/ @haemishkyd /tests/components/poolsense/ @haemishkyd +/homeassistant/components/powerfox/ @klaasnicolaas +/tests/components/powerfox/ @klaasnicolaas /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/private_ble_device/ @Jc2k @@ -1355,6 +1365,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 +/homeassistant/components/slide_local/ @dontinelli +/tests/components/slide_local/ @dontinelli /homeassistant/components/slimproto/ @marcelveldt /tests/components/slimproto/ @marcelveldt /homeassistant/components/sma/ @kellerza @rklomp @@ -1413,15 +1425,13 @@ build.json @home-assistant/supervisor /tests/components/starline/ @anonym-tsk /homeassistant/components/starlink/ @boswelja /tests/components/starlink/ @boswelja -/homeassistant/components/statistics/ @ThomDietrich -/tests/components/statistics/ @ThomDietrich +/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST +/tests/components/statistics/ @ThomDietrich @gjohansson-ST /homeassistant/components/steam_online/ @tkdrob /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco /homeassistant/components/stiebel_eltron/ @fucm -/homeassistant/components/stookalert/ @fwestenberg @frenck -/tests/components/stookalert/ @fwestenberg @frenck /homeassistant/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter @@ -1644,6 +1654,8 @@ build.json @home-assistant/supervisor /tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core +/homeassistant/components/watergate/ @adam-the-hero +/tests/components/watergate/ @adam-the-hero /homeassistant/components/watson_tts/ @rutkai /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya diff --git a/Dockerfile b/Dockerfile index 61d64212b40..630fc19496c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.4 +RUN pip3 install uv==0.5.8 WORKDIR /usr/src diff --git a/Dockerfile.dev b/Dockerfile.dev index 48f582a1581..5a3f1a2ae64 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.12 +FROM mcr.microsoft.com/devcontainers/python:1-3.13 SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/build.yaml b/build.yaml index a8755bbbf5c..fafdd876f75 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.12.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.12.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.12.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.12.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.12.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 21a4b6113d0..afe3b2d7aa3 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -115,7 +115,7 @@ class AuthManagerFlowManager( *, context: AuthFlowContext | None = None, data: dict[str, Any] | None = None, - ) -> LoginFlow: + ) -> LoginFlow[Any]: """Create a login flow.""" auth_provider = self.auth_manager.get_auth_provider(*handler_key) if not auth_provider: diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index d57a274c7ff..8a6430d770a 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -4,8 +4,9 @@ from __future__ import annotations import logging import types -from typing import Any +from typing import Any, Generic +from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -34,6 +35,12 @@ DATA_REQS: HassKey[set[str]] = HassKey("mfa_auth_module_reqs_processed") _LOGGER = logging.getLogger(__name__) +_MultiFactorAuthModuleT = TypeVar( + "_MultiFactorAuthModuleT", + bound="MultiFactorAuthModule", + default="MultiFactorAuthModule", +) + class MultiFactorAuthModule: """Multi-factor Auth Module of validation function.""" @@ -71,7 +78,7 @@ class MultiFactorAuthModule: """Return a voluptuous schema to define mfa auth module's input.""" raise NotImplementedError - async def async_setup_flow(self, user_id: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> SetupFlow[Any]: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -95,11 +102,14 @@ class MultiFactorAuthModule: raise NotImplementedError -class SetupFlow(data_entry_flow.FlowHandler): +class SetupFlow(data_entry_flow.FlowHandler, Generic[_MultiFactorAuthModuleT]): """Handler for the setup flow.""" def __init__( - self, auth_module: MultiFactorAuthModule, setup_schema: vol.Schema, user_id: str + self, + auth_module: _MultiFactorAuthModuleT, + setup_schema: vol.Schema, + user_id: str, ) -> None: """Initialize the setup flow.""" self._auth_module = auth_module diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index d2010dc2c9d..b60a3012aac 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -162,7 +162,7 @@ class NotifyAuthModule(MultiFactorAuthModule): return sorted(unordered_services) - async def async_setup_flow(self, user_id: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> NotifySetupFlow: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -268,7 +268,7 @@ class NotifyAuthModule(MultiFactorAuthModule): await self.hass.services.async_call("notify", notify_service, data) -class NotifySetupFlow(SetupFlow): +class NotifySetupFlow(SetupFlow[NotifyAuthModule]): """Handler for the setup flow.""" def __init__( @@ -280,8 +280,6 @@ class NotifySetupFlow(SetupFlow): ) -> None: """Initialize the setup flow.""" super().__init__(auth_module, setup_schema, user_id) - # to fix typing complaint - self._auth_module: NotifyAuthModule = auth_module self._available_notify_services = available_notify_services self._secret: str | None = None self._count: int | None = None diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 3306f76217f..625b273f39a 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -114,7 +114,7 @@ class TotpAuthModule(MultiFactorAuthModule): self._users[user_id] = ota_secret # type: ignore[index] return ota_secret - async def async_setup_flow(self, user_id: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> TotpSetupFlow: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -174,10 +174,9 @@ class TotpAuthModule(MultiFactorAuthModule): return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1)) -class TotpSetupFlow(SetupFlow): +class TotpSetupFlow(SetupFlow[TotpAuthModule]): """Handler for the setup flow.""" - _auth_module: TotpAuthModule _ota_secret: str _url: str _image: str diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 34278c47df7..02f99e7bd71 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -5,8 +5,9 @@ from __future__ import annotations from collections.abc import Mapping import logging import types -from typing import Any +from typing import Any, Generic +from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -46,6 +47,8 @@ AUTH_PROVIDER_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_AuthProviderT = TypeVar("_AuthProviderT", bound="AuthProvider", default="AuthProvider") + class AuthProvider: """Provider of user authentication.""" @@ -105,7 +108,7 @@ class AuthProvider: # Implement by extending class - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow[Any]: """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. @@ -192,12 +195,15 @@ async def load_auth_provider_module( return module -class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]): +class LoginFlow( + FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]], + Generic[_AuthProviderT], +): """Handler for the login flow.""" _flow_result = AuthFlowResult - def __init__(self, auth_provider: AuthProvider) -> None: + def __init__(self, auth_provider: _AuthProviderT) -> None: """Initialize the login flow.""" self._auth_provider = auth_provider self._auth_module_id: str | None = None diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 12447bc8c18..74630d925e1 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Mapping import logging import os -from typing import Any, cast +from typing import Any import voluptuous as vol @@ -59,7 +59,9 @@ class CommandLineAuthProvider(AuthProvider): super().__init__(*args, **kwargs) self._user_meta: dict[str, dict[str, Any]] = {} - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow( + self, context: AuthFlowContext | None + ) -> CommandLineLoginFlow: """Return a flow to login.""" return CommandLineLoginFlow(self) @@ -133,7 +135,7 @@ class CommandLineAuthProvider(AuthProvider): ) -class CommandLineLoginFlow(LoginFlow): +class CommandLineLoginFlow(LoginFlow[CommandLineAuthProvider]): """Handler for the login flow.""" async def async_step_init( @@ -145,9 +147,9 @@ class CommandLineLoginFlow(LoginFlow): if user_input is not None: user_input["username"] = user_input["username"].strip() try: - await cast( - CommandLineAuthProvider, self._auth_provider - ).async_validate_login(user_input["username"], user_input["password"]) + await self._auth_provider.async_validate_login( + user_input["username"], user_input["password"] + ) except InvalidAuthError: errors["base"] = "invalid_auth" diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index e5dded74762..522e5d77a29 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -305,7 +305,7 @@ class HassAuthProvider(AuthProvider): await data.async_load() self.data = data - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow(self, context: AuthFlowContext | None) -> HassLoginFlow: """Return a flow to login.""" return HassLoginFlow(self) @@ -400,7 +400,7 @@ class HassAuthProvider(AuthProvider): pass -class HassLoginFlow(LoginFlow): +class HassLoginFlow(LoginFlow[HassAuthProvider]): """Handler for the login flow.""" async def async_step_init( @@ -411,7 +411,7 @@ class HassLoginFlow(LoginFlow): if user_input is not None: try: - await cast(HassAuthProvider, self._auth_provider).async_validate_login( + await self._auth_provider.async_validate_login( user_input["username"], user_input["password"] ) except InvalidAuth: diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index a7dced851a3..a92f5b55848 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping import hmac -from typing import cast import voluptuous as vol @@ -36,7 +35,9 @@ class InvalidAuthError(HomeAssistantError): class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow( + self, context: AuthFlowContext | None + ) -> ExampleLoginFlow: """Return a flow to login.""" return ExampleLoginFlow(self) @@ -93,7 +94,7 @@ class ExampleAuthProvider(AuthProvider): return UserMeta(name=name, is_active=True) -class ExampleLoginFlow(LoginFlow): +class ExampleLoginFlow(LoginFlow[ExampleAuthProvider]): """Handler for the login flow.""" async def async_step_init( @@ -104,7 +105,7 @@ class ExampleLoginFlow(LoginFlow): if user_input is not None: try: - cast(ExampleAuthProvider, self._auth_provider).async_validate_login( + self._auth_provider.async_validate_login( user_input["username"], user_input["password"] ) except InvalidAuthError: diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index f32c35d4bd5..799fd4d2e16 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -104,7 +104,9 @@ class TrustedNetworksAuthProvider(AuthProvider): """Trusted Networks auth provider does not support MFA.""" return False - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow( + self, context: AuthFlowContext | None + ) -> TrustedNetworksLoginFlow: """Return a flow to login.""" assert context is not None ip_addr = cast(IPAddress, context.get("ip_address")) @@ -214,7 +216,7 @@ class TrustedNetworksAuthProvider(AuthProvider): self.async_validate_access(ip_address(remote_ip)) -class TrustedNetworksLoginFlow(LoginFlow): +class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]): """Handler for the login flow.""" def __init__( @@ -235,9 +237,7 @@ class TrustedNetworksLoginFlow(LoginFlow): ) -> AuthFlowResult: """Handle the step of the form.""" try: - cast( - TrustedNetworksAuthProvider, self._auth_provider - ).async_validate_access(self._ip_address) + self._auth_provider.async_validate_access(self._ip_address) except InvalidAuthError: return self.async_abort(reason="not_allowed") diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 32991dfb2d3..f9250e3129e 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -1,6 +1,10 @@ """Home Assistant module to handle restoring backups.""" +from __future__ import annotations + +from collections.abc import Iterable from dataclasses import dataclass +import hashlib import json import logging from pathlib import Path @@ -14,7 +18,12 @@ import securetar from .const import __version__ as HA_VERSION RESTORE_BACKUP_FILE = ".HA_RESTORE" -KEEP_PATHS = ("backups",) +KEEP_BACKUPS = ("backups",) +KEEP_DATABASE = ( + "home-assistant_v2.db", + "home-assistant_v2.db-wal", +) + _LOGGER = logging.getLogger(__name__) @@ -24,6 +33,21 @@ class RestoreBackupFileContent: """Definition for restore backup file content.""" backup_file_path: Path + password: str | None + remove_after_restore: bool + restore_database: bool + restore_homeassistant: bool + + +def password_to_key(password: str) -> bytes: + """Generate a AES Key from password. + + Matches the implementation in supervisor.backups.utils.password_to_key. + """ + key: bytes = password.encode() + for _ in range(100): + key = hashlib.sha256(key).digest() + return key[:16] def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None: @@ -32,20 +56,24 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | try: instruction_content = json.loads(instruction_path.read_text(encoding="utf-8")) return RestoreBackupFileContent( - backup_file_path=Path(instruction_content["path"]) + backup_file_path=Path(instruction_content["path"]), + password=instruction_content["password"], + remove_after_restore=instruction_content["remove_after_restore"], + restore_database=instruction_content["restore_database"], + restore_homeassistant=instruction_content["restore_homeassistant"], ) - except (FileNotFoundError, json.JSONDecodeError): + except (FileNotFoundError, KeyError, json.JSONDecodeError): return None -def _clear_configuration_directory(config_dir: Path) -> None: - """Delete all files and directories in the config directory except for the backups directory.""" - keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS] - config_contents = sorted( - [entry for entry in config_dir.iterdir() if entry not in keep_paths] +def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None: + """Delete all files and directories in the config directory except entries in the keep list.""" + keep_paths = [config_dir.joinpath(path) for path in keep] + entries_to_remove = sorted( + entry for entry in config_dir.iterdir() if entry not in keep_paths ) - for entry in config_contents: + for entry in entries_to_remove: entrypath = config_dir.joinpath(entry) if entrypath.is_file(): @@ -54,12 +82,15 @@ def _clear_configuration_directory(config_dir: Path) -> None: shutil.rmtree(entrypath) -def _extract_backup(config_dir: Path, backup_file_path: Path) -> None: +def _extract_backup( + config_dir: Path, + restore_content: RestoreBackupFileContent, +) -> None: """Extract the backup file to the config directory.""" with ( TemporaryDirectory() as tempdir, securetar.SecureTarFile( - backup_file_path, + restore_content.backup_file_path, gzip=False, mode="r", ) as ostf, @@ -88,22 +119,41 @@ def _extract_backup(config_dir: Path, backup_file_path: Path) -> None: f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}", ), gzip=backup_meta["compressed"], + key=password_to_key(restore_content.password) + if restore_content.password is not None + else None, mode="r", ) as istf: - for member in istf.getmembers(): - if member.name == "data": - continue - member.name = member.name.replace("data/", "") - _clear_configuration_directory(config_dir) istf.extractall( - path=config_dir, - members=[ - member - for member in securetar.secure_path(istf) - if member.name != "data" - ], + path=Path(tempdir, "homeassistant"), + members=securetar.secure_path(istf), filter="fully_trusted", ) + if restore_content.restore_homeassistant: + keep = list(KEEP_BACKUPS) + if not restore_content.restore_database: + keep.extend(KEEP_DATABASE) + _clear_configuration_directory(config_dir, keep) + shutil.copytree( + Path(tempdir, "homeassistant", "data"), + config_dir, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(*(keep)), + ) + elif restore_content.restore_database: + for entry in KEEP_DATABASE: + entrypath = config_dir / entry + + if entrypath.is_file(): + entrypath.unlink() + elif entrypath.is_dir(): + shutil.rmtree(entrypath) + + for entry in KEEP_DATABASE: + shutil.copy( + Path(tempdir, "homeassistant", "data", entry), + config_dir, + ) def restore_backup(config_dir_path: str) -> bool: @@ -119,8 +169,13 @@ def restore_backup(config_dir_path: str) -> bool: backup_file_path = restore_content.backup_file_path _LOGGER.info("Restoring %s", backup_file_path) try: - _extract_backup(config_dir, backup_file_path) + _extract_backup( + config_dir=config_dir, + restore_content=restore_content, + ) except FileNotFoundError as err: raise ValueError(f"Backup file {backup_file_path} does not exist") from err + if restore_content.remove_after_restore: + backup_file_path.unlink(missing_ok=True) _LOGGER.info("Restore complete, restarting") return True diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 7a68b2515e9..767716dbe27 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -50,6 +50,12 @@ def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool: return False +def _check_load_verify_locations_call_allowed(mapped_args: dict[str, Any]) -> bool: + # If only cadata is passed, we can ignore it + kwargs = mapped_args.get("kwargs") + return bool(kwargs and len(kwargs) == 1 and "cadata" in kwargs) + + @dataclass(slots=True, frozen=True) class BlockingCall: """Class to hold information about a blocking call.""" @@ -158,7 +164,7 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = ( original_func=SSLContext.load_verify_locations, object=SSLContext, function="load_verify_locations", - check_allowed=None, + check_allowed=_check_load_verify_locations_call_allowed, strict=False, strict_core=False, skip_for_tests=True, diff --git a/homeassistant/brands/slide.json b/homeassistant/brands/slide.json new file mode 100644 index 00000000000..808a54affc3 --- /dev/null +++ b/homeassistant/brands/slide.json @@ -0,0 +1,5 @@ +{ + "domain": "slide", + "name": "Slide", + "integrations": ["slide", "slide_local"] +} diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index d69aad80875..e2d0a331f0a 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -9,18 +9,16 @@ from jaraco.abode.devices.light import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from . import AbodeSystem from .const import DOMAIN @@ -44,13 +42,13 @@ class AbodeLight(AbodeDevice, LightEntity): _device: Light _attr_name = None + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN def turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable: - self._device.set_color_temp( - int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])) - ) + if ATTR_COLOR_TEMP_KELVIN in kwargs and self._device.is_color_capable: + self._device.set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN]) return if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: @@ -85,10 +83,10 @@ class AbodeLight(AbodeDevice, LightEntity): return None @property - def color_temp(self) -> int | None: + def color_temp_kelvin(self) -> int | None: """Return the color temp of the light.""" if self._device.has_color: - return color_temperature_kelvin_to_mired(self._device.color_temp) + return int(self._device.color_temp) return None @property diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py index 9aa4b92e932..ecb7ac06eb5 100644 --- a/homeassistant/components/acaia/binary_sensor.py +++ b/homeassistant/components/acaia/binary_sensor.py @@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class AcaiaBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index 3f3e1c14d58..c1f1fdd7a81 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -25,5 +25,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioacaia"], - "requirements": ["aioacaia==0.1.10"] + "requirements": ["aioacaia==0.1.11"] } diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index 6e6ce6afcb8..7ba44958eca 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -21,6 +21,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class AcaiaSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index ac381ff46d5..15022ba3c9f 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -75,7 +75,6 @@ class AdaxDevice(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: """Initialize the heater.""" diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 8da46cc7463..d07a3182ed7 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -102,7 +102,6 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None - _enable_turn_on_off_backwards_compatibility = False _support_preset = ClimateEntityFeature(0) def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: @@ -261,7 +260,6 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _enable_turn_on_off_backwards_compatibility = False def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index dbb6f02859b..0af920bd7a9 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -95,7 +95,6 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, ac_number, info): """Initialize the climate device.""" @@ -205,7 +204,6 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = AT_GROUP_MODES - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py index dfc34c1beaf..16566f5d664 100644 --- a/homeassistant/components/airtouch5/climate.py +++ b/homeassistant/components/airtouch5/climate.py @@ -124,7 +124,6 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity): _attr_translation_key = DOMAIN _attr_target_temperature_step = 1 _attr_name = None - _enable_turn_on_off_backwards_compatibility = False class Airtouch5AC(Airtouch5ClimateEntity): diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 6be7416bbb0..4ed54286cff 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -136,7 +136,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index d32b070ad8c..b98473072e4 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -177,7 +177,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def _init_attributes(self) -> None: """Init common climate device attributes.""" @@ -194,12 +193,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) - if ( - self.get_airzone_value(AZD_SPEED) is not None - and self.get_airzone_value(AZD_SPEEDS) is not None - ): - self._initialize_fan_speeds() - @callback def _handle_coordinator_update(self) -> None: """Update attributes when the coordinator updates.""" @@ -214,8 +207,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ self.get_airzone_value(AZD_ACTION) ] - if self.supported_features & ClimateEntityFeature.FAN_MODE: - self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) if self.get_airzone_value(AZD_POWER): self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ self.get_airzone_value(AZD_MODE) @@ -252,6 +243,22 @@ class AirzoneDeviceClimate(AirzoneClimate): _speeds: dict[int, str] _speeds_reverse: dict[str, int] + def _init_attributes(self) -> None: + """Init common climate device attributes.""" + super()._init_attributes() + if ( + self.get_airzone_value(AZD_SPEED) is not None + and self.get_airzone_value(AZD_SPEEDS) is not None + ): + self._initialize_fan_speeds() + + @callback + def _async_update_attrs(self) -> None: + """Update climate attributes.""" + super()._async_update_attrs() + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) + def _initialize_fan_speeds(self) -> None: """Initialize fan speeds.""" azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 5bb00360177..4c5e201df8f 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -355,12 +355,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" - features = self._attr_supported_features - if type(features) is int: # noqa: E721 - new_features = AlarmControlPanelEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features + return self._attr_supported_features @final @property diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index b2cda8ad76e..c5b4ad15904 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -317,6 +317,7 @@ class Alexa(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -403,6 +404,7 @@ class AlexaPowerController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -436,7 +438,7 @@ class AlexaPowerController(AlexaCapability): elif self.entity.domain == remote.DOMAIN: is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) elif self.entity.domain == vacuum.DOMAIN: - is_on = self.entity.state == vacuum.STATE_CLEANING + is_on = self.entity.state == vacuum.VacuumActivity.CLEANING elif self.entity.domain == timer.DOMAIN: is_on = self.entity.state != STATE_IDLE elif self.entity.domain == water_heater.DOMAIN: @@ -469,6 +471,7 @@ class AlexaLockController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -523,6 +526,7 @@ class AlexaSceneController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -562,6 +566,7 @@ class AlexaBrightnessController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -611,6 +616,7 @@ class AlexaColorController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -669,6 +675,7 @@ class AlexaColorTemperatureController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -715,6 +722,7 @@ class AlexaSpeaker(AlexaCapability): "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", "ja-JP", + "nl-NL", } def name(self) -> str: @@ -772,6 +780,7 @@ class AlexaStepSpeaker(AlexaCapability): "es-ES", "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", + "nl-NL", } def name(self) -> str: @@ -801,6 +810,7 @@ class AlexaPlaybackController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -859,6 +869,7 @@ class AlexaInputController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -1104,6 +1115,7 @@ class AlexaThermostatController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -1245,6 +1257,7 @@ class AlexaPowerLevelController(AlexaCapability): "fr-CA", "fr-FR", "it-IT", + "nl-NL", "ja-JP", } @@ -1723,6 +1736,7 @@ class AlexaRangeController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2066,6 +2080,7 @@ class AlexaToggleController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2212,6 +2227,7 @@ class AlexaPlaybackStateReporter(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2267,6 +2283,7 @@ class AlexaSeekController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2360,6 +2377,7 @@ class AlexaEqualizerController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2470,6 +2488,7 @@ class AlexaCameraStreamController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 4862e4d8a8c..27e9bbd5b67 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -59,6 +59,7 @@ CONF_SUPPORTED_LOCALES = ( "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", ) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 89e47673f07..04bef105546 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -359,7 +359,7 @@ async def async_api_set_color_temperature( await hass.services.async_call( entity.domain, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin}, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: kelvin}, blocking=False, context=context, ) @@ -376,14 +376,14 @@ async def async_api_decrease_color_temp( ) -> AlexaResponse: """Process a decrease color temperature request.""" entity = directive.entity - current = int(entity.attributes[light.ATTR_COLOR_TEMP]) - max_mireds = int(entity.attributes[light.ATTR_MAX_MIREDS]) + current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) + min_kelvin = int(entity.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN]) - value = min(max_mireds, current + 50) + value = max(min_kelvin, current - 500) await hass.services.async_call( entity.domain, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value}, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value}, blocking=False, context=context, ) @@ -400,14 +400,14 @@ async def async_api_increase_color_temp( ) -> AlexaResponse: """Process an increase color temperature request.""" entity = directive.entity - current = int(entity.attributes[light.ATTR_COLOR_TEMP]) - min_mireds = int(entity.attributes[light.ATTR_MIN_MIREDS]) + current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) + max_kelvin = int(entity.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN]) - value = max(min_mireds, current - 50) + value = min(max_kelvin, current + 500) await hass.services.async_call( entity.domain, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value}, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value}, blocking=False, context=context, ) @@ -527,6 +527,7 @@ async def async_api_unlock( "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", }: msg = ( diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 34c4212c913..4ffa0e24777 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -110,7 +110,7 @@ def _setup_androidtv( adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" else: - # Use "pure-python-adb" (communicate with ADB server) + # Communicate via ADB server signer = None adb_log = ( "using ADB server at" @@ -135,15 +135,16 @@ async def async_connect_androidtv( ) aftv = await async_androidtv_setup( - config[CONF_HOST], - config[CONF_PORT], - adbkey, - config.get(CONF_ADB_SERVER_IP), - config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT), - state_detection_rules, - config[CONF_DEVICE_CLASS], - timeout, - signer, + host=config[CONF_HOST], + port=config[CONF_PORT], + adbkey=adbkey, + adb_server_ip=config.get(CONF_ADB_SERVER_IP), + adb_server_port=config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT), + state_detection_rules=state_detection_rules, + device_class=config[CONF_DEVICE_CLASS], + auth_timeout_s=timeout, + signer=signer, + log_errors=False, ) if not aftv.available: diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py index 626dd0f7794..fa583bb2777 100644 --- a/homeassistant/components/androidtv/entity.py +++ b/homeassistant/components/androidtv/entity.py @@ -151,5 +151,5 @@ class AndroidTVEntity(Entity): # Using "adb_shell" (Python ADB implementation) self.exceptions = ADB_PYTHON_EXCEPTIONS else: - # Using "pure-python-adb" (communicate with ADB server) + # Communicate via ADB server self.exceptions = ADB_TCP_EXCEPTIONS diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index fe8e36f0c2f..e30d03fc2d5 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -6,10 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "integration_type": "device", "iot_class": "local_polling", - "loggers": ["adb_shell", "androidtv", "pure_python_adb"], - "requirements": [ - "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.75", - "pure-python-adb[async]==0.3.0.dev0" - ] + "loggers": ["adb_shell", "androidtv"], + "requirements": ["adb-shell[async]==0.4.4", "androidtv[async]==0.0.75"] } diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index eae7981d5b9..a928a6677cb 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.11"] + "requirements": ["py-aosmith==1.0.12"] } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 5bbc81adb86..7dda24c4023 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -16,6 +16,7 @@ import time from typing import Any, Literal, cast import wave +import hass_nabucasa import voluptuous as vol from homeassistant.components import ( @@ -29,6 +30,7 @@ from homeassistant.components import ( from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) +from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent @@ -917,6 +919,11 @@ class PipelineRun: ) except (asyncio.CancelledError, TimeoutError): raise # expected + except hass_nabucasa.auth.Unauthenticated as src_error: + raise SpeechToTextError( + code="cloud-auth-failed", + message="Home Assistant Cloud authentication failed", + ) from src_error except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") raise SpeechToTextError( @@ -1009,15 +1016,23 @@ class PipelineRun: if self.intent_agent is None: raise RuntimeError("Recognize intent was not prepared") + if self.pipeline.conversation_language == MATCH_ALL: + # LLMs support all languages ('*') so use pipeline language for + # intent fallback. + input_language = self.pipeline.language + else: + input_language = self.pipeline.conversation_language + self.process_event( PipelineEvent( PipelineEventType.INTENT_START, { "engine": self.intent_agent, - "language": self.pipeline.conversation_language, + "language": input_language, "intent_input": intent_input, "conversation_id": conversation_id, "device_id": device_id, + "prefer_local_intents": self.pipeline.prefer_local_intents, }, ) ) @@ -1028,9 +1043,10 @@ class PipelineRun: context=self.context, conversation_id=conversation_id, device_id=device_id, - language=self.pipeline.language, + language=input_language, agent_id=self.intent_agent, ) + processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT conversation_result: conversation.ConversationResult | None = None if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: @@ -1061,6 +1077,7 @@ class PipelineRun: response=intent_response, conversation_id=user_input.conversation_id, ) + processed_locally = True if conversation_result is None: # Fall back to pipeline conversation agent @@ -1085,7 +1102,10 @@ class PipelineRun: self.process_event( PipelineEvent( PipelineEventType.INTENT_END, - {"intent_output": conversation_result.as_dict()}, + { + "processed_locally": processed_locally, + "intent_output": conversation_result.as_dict(), + }, ) ) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index deae5b9b7b3..c7fe1bc10c7 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -140,7 +140,7 @@ class VoiceCommandSegmenter: self._timeout_seconds_left -= chunk_seconds if self._timeout_seconds_left <= 0: - _LOGGER.warning( + _LOGGER.debug( "VAD end of speech detection timed out after %s seconds", self.timeout_seconds, ) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index daeb64f7f0a..a362b71fbc8 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -46,7 +46,6 @@ class AtagThermostat(AtagEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: AtagDataUpdateCoordinator, atag_id: str) -> None: """Initialize an Atag climate device.""" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 96ed982e4ec..d0b41411c96 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.5"] } diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index 877a46a3650..456b8962461 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "iot_class": "cloud_polling", "loggers": ["aussiebb"], - "requirements": ["pyaussiebb==0.0.15"] + "requirements": ["pyaussiebb==0.1.4"] } diff --git a/homeassistant/components/autarco/__init__.py b/homeassistant/components/autarco/__init__.py index 0e29b25ad80..f42bfdf4a0e 100644 --- a/homeassistant/components/autarco/__init__.py +++ b/homeassistant/components/autarco/__init__.py @@ -4,11 +4,12 @@ from __future__ import annotations import asyncio -from autarco import Autarco +from autarco import Autarco, AutarcoConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import AutarcoDataUpdateCoordinator @@ -25,7 +26,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutarcoConfigEntry) -> b password=entry.data[CONF_PASSWORD], session=async_get_clientsession(hass), ) - account_sites = await client.get_account() + + try: + account_sites = await client.get_account() + except AutarcoConnectionError as err: + await client.close() + raise ConfigEntryNotReady from err coordinators: list[AutarcoDataUpdateCoordinator] = [ AutarcoDataUpdateCoordinator(hass, client, site) for site in account_sites diff --git a/homeassistant/components/autarco/strings.json b/homeassistant/components/autarco/strings.json index 159dbd09781..a053cd36e09 100644 --- a/homeassistant/components/autarco/strings.json +++ b/homeassistant/components/autarco/strings.json @@ -28,7 +28,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 7163437361a..9758af60178 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==63"], + "requirements": ["axis==64"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 200cb4a3f65..f1a6f3be196 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -5,36 +5,81 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType -from .const import DATA_MANAGER, DOMAIN, LOGGER +from .agent import ( + BackupAgent, + BackupAgentError, + BackupAgentPlatformProtocol, + LocalBackupAgent, +) +from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views -from .manager import BackupManager +from .manager import ( + BackupManager, + BackupPlatformProtocol, + BackupReaderWriter, + CoreBackupReaderWriter, + CreateBackupEvent, + ManagerBackup, + NewBackup, + WrittenBackup, +) +from .models import AddonInfo, AgentBackup, Folder from .websocket import async_register_websocket_handlers +__all__ = [ + "AddonInfo", + "AgentBackup", + "ManagerBackup", + "BackupAgent", + "BackupAgentError", + "BackupAgentPlatformProtocol", + "BackupPlatformProtocol", + "BackupReaderWriter", + "CreateBackupEvent", + "Folder", + "LocalBackupAgent", + "NewBackup", + "WrittenBackup", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" - backup_manager = BackupManager(hass) - hass.data[DATA_MANAGER] = backup_manager - with_hassio = is_hassio(hass) + reader_writer: BackupReaderWriter + if not with_hassio: + reader_writer = CoreBackupReaderWriter(hass) + else: + # pylint: disable-next=import-outside-toplevel, hass-component-root-import + from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter + + reader_writer = SupervisorBackupReaderWriter(hass) + + backup_manager = BackupManager(hass, reader_writer) + hass.data[DATA_MANAGER] = backup_manager + await backup_manager.async_setup() + async_register_websocket_handlers(hass, with_hassio) - if with_hassio: - if DOMAIN in config: - LOGGER.error( - "The backup integration is not supported on this installation method, " - "please remove it from your configuration" - ) - return True - async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" - await backup_manager.async_create_backup() + agent_id = list(backup_manager.local_backup_agents)[0] + await backup_manager.async_create_backup( + agent_ids=[agent_id], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) - hass.services.async_register(DOMAIN, "create", async_handle_create_service) + if not with_hassio: + hass.services.async_register(DOMAIN, "create", async_handle_create_service) async_register_http_views(hass) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py new file mode 100644 index 00000000000..44bc9b298e8 --- /dev/null +++ b/homeassistant/components/backup/agent.py @@ -0,0 +1,121 @@ +"""Backup agents for the Backup integration.""" + +from __future__ import annotations + +import abc +from collections.abc import AsyncIterator, Callable, Coroutine +from pathlib import Path +from typing import Any, Protocol + +from propcache import cached_property + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .models import AgentBackup + + +class BackupAgentError(HomeAssistantError): + """Base class for backup agent errors.""" + + +class BackupAgentUnreachableError(BackupAgentError): + """Raised when the agent can't reach its API.""" + + _message = "The backup agent is unreachable." + + +class BackupAgent(abc.ABC): + """Backup agent interface.""" + + domain: str + name: str + + @cached_property + def agent_id(self) -> str: + """Return the agent_id.""" + return f"{self.domain}.{self.name}" + + @abc.abstractmethod + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + + @abc.abstractmethod + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + + @abc.abstractmethod + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + + @abc.abstractmethod + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + + @abc.abstractmethod + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + + +class LocalBackupAgent(BackupAgent): + """Local backup agent.""" + + @abc.abstractmethod + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup. + + The method should return the path to the backup file with the specified id. + """ + + +class BackupAgentPlatformProtocol(Protocol): + """Define the format of backup platforms which implement backup agents.""" + + async def async_get_backup_agents( + self, + hass: HomeAssistant, + **kwargs: Any, + ) -> list[BackupAgent]: + """Return a list of backup agents.""" + + @callback + def async_register_backup_agents_listener( + self, + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, + ) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py new file mode 100644 index 00000000000..ef4924161c2 --- /dev/null +++ b/homeassistant/components/backup/backup.py @@ -0,0 +1,125 @@ +"""Local backup support for Core and Container installations.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import json +from pathlib import Path +from tarfile import TarError +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.hassio import is_hassio + +from .agent import BackupAgent, LocalBackupAgent +from .const import DOMAIN, LOGGER +from .models import AgentBackup +from .util import read_backup + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the local backup agent.""" + if is_hassio(hass): + return [] + return [CoreLocalBackupAgent(hass)] + + +class CoreLocalBackupAgent(LocalBackupAgent): + """Local backup agent for Core and Container installations.""" + + domain = DOMAIN + name = "local" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._backup_dir = Path(hass.config.path("backups")) + self._backups: dict[str, AgentBackup] = {} + self._loaded_backups = False + + async def _load_backups(self) -> None: + """Load data of stored backup files.""" + backups = await self._hass.async_add_executor_job(self._read_backups) + LOGGER.debug("Loaded %s local backups", len(backups)) + self._backups = backups + self._loaded_backups = True + + def _read_backups(self) -> dict[str, AgentBackup]: + """Read backups from disk.""" + backups: dict[str, AgentBackup] = {} + for backup_path in self._backup_dir.glob("*.tar"): + try: + backup = read_backup(backup_path) + backups[backup.backup_id] = backup + except (OSError, TarError, json.JSONDecodeError, KeyError) as err: + LOGGER.warning("Unable to read backup %s: %s", backup_path, err) + return backups + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + raise NotImplementedError + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + self._backups[backup.backup_id] = backup + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + if not self._loaded_backups: + await self._load_backups() + return list(self._backups.values()) + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + if not self._loaded_backups: + await self._load_backups() + + if not (backup := self._backups.get(backup_id)): + return None + + backup_path = self.get_backup_path(backup_id) + if not await self._hass.async_add_executor_job(backup_path.exists): + LOGGER.debug( + ( + "Removing tracked backup (%s) that does not exists on the expected" + " path %s" + ), + backup.backup_id, + backup_path, + ) + self._backups.pop(backup_id) + return None + + return backup + + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup.""" + return self._backup_dir / f"{backup_id}.tar" + + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Delete a backup file.""" + if await self.async_get_backup(backup_id) is None: + return + + backup_path = self.get_backup_path(backup_id) + await self._hass.async_add_executor_job(backup_path.unlink, True) + LOGGER.debug("Deleted backup located at %s", backup_path) + self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py new file mode 100644 index 00000000000..26ce691a4cc --- /dev/null +++ b/homeassistant/components/backup/config.py @@ -0,0 +1,471 @@ +"""Provide persistent configuration for the backup integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +from enum import StrEnum +from typing import TYPE_CHECKING, Self, TypedDict + +from cronsim import CronSim + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.util import dt as dt_util + +from .const import LOGGER +from .models import Folder + +if TYPE_CHECKING: + from .manager import BackupManager, ManagerBackup + +# The time of the automatic backup event should be compatible with +# the time of the recorder's nightly job which runs at 04:12. +# Run the backup at 04:45. +CRON_PATTERN_DAILY = "45 4 * * *" +CRON_PATTERN_WEEKLY = "45 4 * * {}" + + +class StoredBackupConfig(TypedDict): + """Represent the stored backup config.""" + + create_backup: StoredCreateBackupConfig + last_attempted_strategy_backup: str | None + last_completed_strategy_backup: str | None + retention: StoredRetentionConfig + schedule: StoredBackupSchedule + + +@dataclass(kw_only=True) +class BackupConfigData: + """Represent loaded backup config data.""" + + create_backup: CreateBackupConfig + last_attempted_strategy_backup: datetime | None = None + last_completed_strategy_backup: datetime | None = None + retention: RetentionConfig + schedule: BackupSchedule + + @classmethod + def from_dict(cls, data: StoredBackupConfig) -> Self: + """Initialize backup config data from a dict.""" + include_folders_data = data["create_backup"]["include_folders"] + if include_folders_data: + include_folders = [Folder(folder) for folder in include_folders_data] + else: + include_folders = None + retention = data["retention"] + + if last_attempted_str := data["last_attempted_strategy_backup"]: + last_attempted = dt_util.parse_datetime(last_attempted_str) + else: + last_attempted = None + + if last_attempted_str := data["last_completed_strategy_backup"]: + last_completed = dt_util.parse_datetime(last_attempted_str) + else: + last_completed = None + + return cls( + create_backup=CreateBackupConfig( + agent_ids=data["create_backup"]["agent_ids"], + include_addons=data["create_backup"]["include_addons"], + include_all_addons=data["create_backup"]["include_all_addons"], + include_database=data["create_backup"]["include_database"], + include_folders=include_folders, + name=data["create_backup"]["name"], + password=data["create_backup"]["password"], + ), + last_attempted_strategy_backup=last_attempted, + last_completed_strategy_backup=last_completed, + retention=RetentionConfig( + copies=retention["copies"], + days=retention["days"], + ), + schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])), + ) + + def to_dict(self) -> StoredBackupConfig: + """Convert backup config data to a dict.""" + if self.last_attempted_strategy_backup: + last_attempted = self.last_attempted_strategy_backup.isoformat() + else: + last_attempted = None + + if self.last_completed_strategy_backup: + last_completed = self.last_completed_strategy_backup.isoformat() + else: + last_completed = None + + return StoredBackupConfig( + create_backup=self.create_backup.to_dict(), + last_attempted_strategy_backup=last_attempted, + last_completed_strategy_backup=last_completed, + retention=self.retention.to_dict(), + schedule=self.schedule.to_dict(), + ) + + +class BackupConfig: + """Handle backup config.""" + + def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: + """Initialize backup config.""" + self.data = BackupConfigData( + create_backup=CreateBackupConfig(), + retention=RetentionConfig(), + schedule=BackupSchedule(), + ) + self._manager = manager + + def load(self, stored_config: StoredBackupConfig) -> None: + """Load config.""" + self.data = BackupConfigData.from_dict(stored_config) + self.data.schedule.apply(self._manager) + + async def update( + self, + *, + create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, + retention: RetentionParametersDict | UndefinedType = UNDEFINED, + schedule: ScheduleState | UndefinedType = UNDEFINED, + ) -> None: + """Update config.""" + if create_backup is not UNDEFINED: + self.data.create_backup = replace(self.data.create_backup, **create_backup) + if retention is not UNDEFINED: + new_retention = RetentionConfig(**retention) + if new_retention != self.data.retention: + self.data.retention = new_retention + self.data.retention.apply(self._manager) + if schedule is not UNDEFINED: + new_schedule = BackupSchedule(state=schedule) + if new_schedule.to_dict() != self.data.schedule.to_dict(): + self.data.schedule = new_schedule + self.data.schedule.apply(self._manager) + + self._manager.store.save() + + +@dataclass(kw_only=True) +class RetentionConfig: + """Represent the backup retention configuration.""" + + copies: int | None = None + days: int | None = None + + def apply(self, manager: BackupManager) -> None: + """Apply backup retention configuration.""" + if self.days is not None: + self._schedule_next(manager) + else: + self._unschedule_next(manager) + + def to_dict(self) -> StoredRetentionConfig: + """Convert backup retention configuration to a dict.""" + return StoredRetentionConfig( + copies=self.copies, + days=self.days, + ) + + @callback + def _schedule_next( + self, + manager: BackupManager, + ) -> None: + """Schedule the next delete after days.""" + self._unschedule_next(manager) + + async def _delete_backups(now: datetime) -> None: + """Delete backups older than days.""" + self._schedule_next(manager) + + def _backups_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return backups older than days to delete.""" + # we need to check here since we await before + # this filter is applied + if self.days is None: + return {} + now = dt_util.utcnow() + return { + backup_id: backup + for backup_id, backup in backups.items() + if dt_util.parse_datetime(backup.date, raise_on_error=True) + + timedelta(days=self.days) + < now + } + + await _delete_filtered_backups(manager, _backups_filter) + + manager.remove_next_delete_event = async_call_later( + manager.hass, timedelta(days=1), _delete_backups + ) + + @callback + def _unschedule_next(self, manager: BackupManager) -> None: + """Unschedule the next delete after days.""" + if (remove_next_event := manager.remove_next_delete_event) is not None: + remove_next_event() + manager.remove_next_delete_event = None + + +class StoredRetentionConfig(TypedDict): + """Represent the stored backup retention configuration.""" + + copies: int | None + days: int | None + + +class RetentionParametersDict(TypedDict, total=False): + """Represent the parameters for retention.""" + + copies: int | None + days: int | None + + +class StoredBackupSchedule(TypedDict): + """Represent the stored backup schedule configuration.""" + + state: ScheduleState + + +class ScheduleState(StrEnum): + """Represent the schedule state.""" + + NEVER = "never" + DAILY = "daily" + MONDAY = "mon" + TUESDAY = "tue" + WEDNESDAY = "wed" + THURSDAY = "thu" + FRIDAY = "fri" + SATURDAY = "sat" + SUNDAY = "sun" + + +@dataclass(kw_only=True) +class BackupSchedule: + """Represent the backup schedule.""" + + state: ScheduleState = ScheduleState.NEVER + cron_event: CronSim | None = field(init=False, default=None) + + @callback + def apply( + self, + manager: BackupManager, + ) -> None: + """Apply a new schedule. + + There are only three possible state types: never, daily, or weekly. + """ + if self.state is ScheduleState.NEVER: + self._unschedule_next(manager) + return + + if self.state is ScheduleState.DAILY: + self._schedule_next(CRON_PATTERN_DAILY, manager) + else: + self._schedule_next( + CRON_PATTERN_WEEKLY.format(self.state.value), + manager, + ) + + @callback + def _schedule_next( + self, + cron_pattern: str, + manager: BackupManager, + ) -> None: + """Schedule the next backup.""" + self._unschedule_next(manager) + now = dt_util.now() + if (cron_event := self.cron_event) is None: + seed_time = manager.config.data.last_completed_strategy_backup or now + cron_event = self.cron_event = CronSim(cron_pattern, seed_time) + next_time = next(cron_event) + + if next_time < now: + # schedule a backup at next daily time once + # if we missed the last scheduled backup + cron_event = CronSim(CRON_PATTERN_DAILY, now) + next_time = next(cron_event) + # reseed the cron event attribute + # add a day to the next time to avoid scheduling at the same time again + self.cron_event = CronSim(cron_pattern, now + timedelta(days=1)) + + async def _create_backup(now: datetime) -> None: + """Create backup.""" + manager.remove_next_backup_event = None + config_data = manager.config.data + self._schedule_next(cron_pattern, manager) + + # create the backup + try: + await manager.async_create_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_strategy_settings=True, + ) + except Exception: # noqa: BLE001 + # another more specific exception will be added + # and handled in the future + LOGGER.exception("Unexpected error creating automatic backup") + + # delete old backups more numerous than copies + + def _backups_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return oldest backups more numerous than copies to delete.""" + # we need to check here since we await before + # this filter is applied + if config_data.retention.copies is None: + return {} + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: len(backups) - config_data.retention.copies] + ) + + await _delete_filtered_backups(manager, _backups_filter) + + manager.remove_next_backup_event = async_track_point_in_time( + manager.hass, _create_backup, next_time + ) + + def to_dict(self) -> StoredBackupSchedule: + """Convert backup schedule to a dict.""" + return StoredBackupSchedule(state=self.state) + + @callback + def _unschedule_next(self, manager: BackupManager) -> None: + """Unschedule the next backup.""" + if (remove_next_event := manager.remove_next_backup_event) is not None: + remove_next_event() + manager.remove_next_backup_event = None + + +@dataclass(kw_only=True) +class CreateBackupConfig: + """Represent the config for async_create_backup.""" + + agent_ids: list[str] = field(default_factory=list) + include_addons: list[str] | None = None + include_all_addons: bool = False + include_database: bool = True + include_folders: list[Folder] | None = None + name: str | None = None + password: str | None = None + + def to_dict(self) -> StoredCreateBackupConfig: + """Convert create backup config to a dict.""" + return { + "agent_ids": self.agent_ids, + "include_addons": self.include_addons, + "include_all_addons": self.include_all_addons, + "include_database": self.include_database, + "include_folders": self.include_folders, + "name": self.name, + "password": self.password, + } + + +class StoredCreateBackupConfig(TypedDict): + """Represent the stored config for async_create_backup.""" + + agent_ids: list[str] + include_addons: list[str] | None + include_all_addons: bool + include_database: bool + include_folders: list[Folder] | None + name: str | None + password: str | None + + +class CreateBackupParametersDict(TypedDict, total=False): + """Represent the parameters for async_create_backup.""" + + agent_ids: list[str] + include_addons: list[str] | None + include_all_addons: bool + include_database: bool + include_folders: list[Folder] | None + name: str | None + password: str | None + + +async def _delete_filtered_backups( + manager: BackupManager, + backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], +) -> None: + """Delete backups parsed with a filter. + + :param manager: The backup manager. + :param backup_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await manager.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # only delete backups that are created by the backup strategy + backups = { + backup_id: backup + for backup_id, backup in backups.items() + if backup.with_strategy_settings + } + + LOGGER.debug("Total strategy backups: %s", backups) + + filtered_backups = backup_filter(backups) + + if not filtered_backups: + return + + # always delete oldest backup first + filtered_backups = dict( + sorted( + filtered_backups.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(filtered_backups) >= len(backups): + # Never delete the last backup. + last_backup = filtered_backups.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", filtered_backups) + + if not filtered_backups: + return + + backup_ids = list(filtered_backups) + delete_results = await asyncio.gather( + *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index f613f7cc352..c2070a37b2d 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -10,6 +10,7 @@ from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: from .manager import BackupManager +BUF_SIZE = 2**20 * 4 # 4MB DOMAIN = "backup" DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) LOGGER = getLogger(__package__) @@ -22,6 +23,12 @@ EXCLUDE_FROM_BACKUP = [ "*.log.*", "*.log", "backups/*.tar", + "tmp_backups/*.tar", "OZW_Log.txt", "tts/*", ] + +EXCLUDE_DATABASE_FROM_BACKUP = [ + "home-assistant_v2.db", + "home-assistant_v2.db-wal", +] diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 42693035bd3..73a8c8eb602 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -8,10 +8,11 @@ from typing import cast from aiohttp import BodyPartReader from aiohttp.hdrs import CONTENT_DISPOSITION -from aiohttp.web import FileResponse, Request, Response +from aiohttp.web import FileResponse, Request, Response, StreamResponse from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from .const import DATA_MANAGER @@ -27,30 +28,47 @@ def async_register_http_views(hass: HomeAssistant) -> None: class DownloadBackupView(HomeAssistantView): """Generate backup view.""" - url = "/api/backup/download/{slug}" + url = "/api/backup/download/{backup_id}" name = "api:backup:download" async def get( self, request: Request, - slug: str, - ) -> FileResponse | Response: + backup_id: str, + ) -> StreamResponse | FileResponse | Response: """Download a backup file.""" if not request["hass_user"].is_admin: return Response(status=HTTPStatus.UNAUTHORIZED) + try: + agent_id = request.query.getone("agent_id") + except KeyError: + return Response(status=HTTPStatus.BAD_REQUEST) manager = request.app[KEY_HASS].data[DATA_MANAGER] - backup = await manager.async_get_backup(slug=slug) + if agent_id not in manager.backup_agents: + return Response(status=HTTPStatus.BAD_REQUEST) + agent = manager.backup_agents[agent_id] + backup = await agent.async_get_backup(backup_id) - if backup is None or not backup.path.exists(): + # We don't need to check if the path exists, aiohttp.FileResponse will handle + # that + if backup is None: return Response(status=HTTPStatus.NOT_FOUND) - return FileResponse( - path=backup.path.as_posix(), - headers={ - CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" - }, - ) + headers = { + CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" + } + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + return FileResponse(path=path.as_posix(), headers=headers) + + stream = await agent.async_download_backup(backup_id) + response = StreamResponse(status=HTTPStatus.OK, headers=headers) + await response.prepare(request) + async for chunk in stream: + await response.write(chunk) + return response class UploadBackupView(HomeAssistantView): @@ -62,15 +80,24 @@ class UploadBackupView(HomeAssistantView): @require_admin async def post(self, request: Request) -> Response: """Upload a backup file.""" + try: + agent_ids = request.query.getall("agent_id") + except KeyError: + return Response(status=HTTPStatus.BAD_REQUEST) manager = request.app[KEY_HASS].data[DATA_MANAGER] reader = await request.multipart() contents = cast(BodyPartReader, await reader.next()) try: - await manager.async_receive_backup(contents=contents) + await manager.async_receive_backup(contents=contents, agent_ids=agent_ids) except OSError as err: return Response( - body=f"Can't write backup file {err}", + body=f"Can't write backup file: {err}", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + except HomeAssistantError as err: + return Response( + body=f"Can't upload backup file: {err}", status=HTTPStatus.INTERNAL_SERVER_ERROR, ) except asyncio.CancelledError: diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4300f75eed0..66977e568e4 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,49 +4,181 @@ from __future__ import annotations import abc import asyncio -from dataclasses import asdict, dataclass +from collections.abc import AsyncIterator, Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum import hashlib import io import json from pathlib import Path -from queue import SimpleQueue import shutil import tarfile -from tarfile import TarError -from tempfile import TemporaryDirectory import time -from typing import Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, TypedDict import aiohttp from securetar import SecureTarFile, atomic_contents_add -from homeassistant.backup_restore import RESTORE_BACKUP_FILE +from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util -from homeassistant.util.json import json_loads_object -from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER - -BUF_SIZE = 2**20 * 4 # 4MB +from .agent import ( + BackupAgent, + BackupAgentError, + BackupAgentPlatformProtocol, + LocalBackupAgent, +) +from .config import BackupConfig +from .const import ( + BUF_SIZE, + DATA_MANAGER, + DOMAIN, + EXCLUDE_DATABASE_FROM_BACKUP, + EXCLUDE_FROM_BACKUP, + LOGGER, +) +from .models import AgentBackup, Folder +from .store import BackupStore +from .util import make_backup_dir, read_backup -@dataclass(slots=True) -class Backup: +@dataclass(frozen=True, kw_only=True, slots=True) +class NewBackup: + """New backup class.""" + + backup_job_id: str + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerBackup(AgentBackup): """Backup class.""" - slug: str - name: str - date: str - path: Path - size: float + agent_ids: list[str] + failed_agent_ids: list[str] + with_strategy_settings: bool - def as_dict(self) -> dict: - """Return a dict representation of this backup.""" - return {**asdict(self), "path": self.path.as_posix()} + +@dataclass(frozen=True, kw_only=True, slots=True) +class WrittenBackup: + """Written backup class.""" + + backup: AgentBackup + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]] + release_stream: Callable[[], Coroutine[Any, Any, None]] + + +class BackupManagerState(StrEnum): + """Backup state type.""" + + IDLE = "idle" + CREATE_BACKUP = "create_backup" + RECEIVE_BACKUP = "receive_backup" + RESTORE_BACKUP = "restore_backup" + + +class CreateBackupStage(StrEnum): + """Create backup stage enum.""" + + ADDON_REPOSITORIES = "addon_repositories" + ADDONS = "addons" + AWAIT_ADDON_RESTARTS = "await_addon_restarts" + DOCKER_CONFIG = "docker_config" + FINISHING_FILE = "finishing_file" + FOLDERS = "folders" + HOME_ASSISTANT = "home_assistant" + UPLOAD_TO_AGENTS = "upload_to_agents" + + +class CreateBackupState(StrEnum): + """Create backup state enum.""" + + COMPLETED = "completed" + FAILED = "failed" + IN_PROGRESS = "in_progress" + + +class ReceiveBackupStage(StrEnum): + """Receive backup stage enum.""" + + RECEIVE_FILE = "receive_file" + UPLOAD_TO_AGENTS = "upload_to_agents" + + +class ReceiveBackupState(StrEnum): + """Receive backup state enum.""" + + COMPLETED = "completed" + FAILED = "failed" + IN_PROGRESS = "in_progress" + + +class RestoreBackupStage(StrEnum): + """Restore backup stage enum.""" + + ADDON_REPOSITORIES = "addon_repositories" + ADDONS = "addons" + AWAIT_ADDON_RESTARTS = "await_addon_restarts" + AWAIT_HOME_ASSISTANT_RESTART = "await_home_assistant_restart" + CHECK_HOME_ASSISTANT = "check_home_assistant" + DOCKER_CONFIG = "docker_config" + DOWNLOAD_FROM_AGENT = "download_from_agent" + FOLDERS = "folders" + HOME_ASSISTANT = "home_assistant" + REMOVE_DELTA_ADDONS = "remove_delta_addons" + + +class RestoreBackupState(StrEnum): + """Receive backup state enum.""" + + COMPLETED = "completed" + FAILED = "failed" + IN_PROGRESS = "in_progress" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerStateEvent: + """Backup state class.""" + + manager_state: BackupManagerState + + +@dataclass(frozen=True, kw_only=True, slots=True) +class IdleEvent(ManagerStateEvent): + """Backup manager idle.""" + + manager_state: BackupManagerState = BackupManagerState.IDLE + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CreateBackupEvent(ManagerStateEvent): + """Backup in progress.""" + + manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP + stage: CreateBackupStage | None + state: CreateBackupState + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ReceiveBackupEvent(ManagerStateEvent): + """Backup receive.""" + + manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP + stage: ReceiveBackupStage | None + state: ReceiveBackupState + + +@dataclass(frozen=True, kw_only=True, slots=True) +class RestoreBackupEvent(ManagerStateEvent): + """Backup restore.""" + + manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP + stage: RestoreBackupStage | None + state: RestoreBackupState class BackupPlatformProtocol(Protocol): @@ -59,40 +191,170 @@ class BackupPlatformProtocol(Protocol): """Perform operations after a backup finishes.""" -class BaseBackupManager(abc.ABC): +class BackupReaderWriter(abc.ABC): + """Abstract class for reading and writing backups.""" + + @abc.abstractmethod + async def async_create_backup( + self, + *, + agent_ids: list[str], + backup_name: str, + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + on_progress: Callable[[ManagerStateEvent], None], + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: + """Create a backup.""" + + @abc.abstractmethod + async def async_receive_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + suggested_filename: str, + ) -> WrittenBackup: + """Receive a backup.""" + + @abc.abstractmethod + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Restore a backup.""" + + +class BackupManager: """Define the format that backup managers can have.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, reader_writer: BackupReaderWriter) -> None: """Initialize the backup manager.""" self.hass = hass - self.backing_up = False - self.backups: dict[str, Backup] = {} - self.loaded_platforms = False self.platforms: dict[str, BackupPlatformProtocol] = {} + self.backup_agent_platforms: dict[str, BackupAgentPlatformProtocol] = {} + self.backup_agents: dict[str, BackupAgent] = {} + self.local_backup_agents: dict[str, LocalBackupAgent] = {} + + self.config = BackupConfig(hass, self) + self._reader_writer = reader_writer + self.known_backups = KnownBackups(self) + self.store = BackupStore(hass, self) + + # Tasks and flags tracking backup and restore progress + self._backup_task: asyncio.Task[WrittenBackup] | None = None + self._backup_finish_task: asyncio.Task[None] | None = None + + # Backup schedule and retention listeners + self.remove_next_backup_event: Callable[[], None] | None = None + self.remove_next_delete_event: Callable[[], None] | None = None + + # Latest backup event and backup event subscribers + self.last_event: ManagerStateEvent = IdleEvent() + self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + + async def async_setup(self) -> None: + """Set up the backup manager.""" + stored = await self.store.load() + if stored: + self.config.load(stored["config"]) + self.known_backups.load(stored["backups"]) + + await self.load_platforms() + + @property + def state(self) -> BackupManagerState: + """Return the state of the backup manager.""" + return self.last_event.manager_state @callback - def _add_platform( + def _add_platform_pre_post_handler( self, - hass: HomeAssistant, integration_domain: str, platform: BackupPlatformProtocol, ) -> None: - """Add a platform to the backup manager.""" + """Add a backup platform.""" if not hasattr(platform, "async_pre_backup") or not hasattr( platform, "async_post_backup" ): - LOGGER.warning( - "%s does not implement required functions for the backup platform", - integration_domain, - ) return + self.platforms[integration_domain] = platform - async def async_pre_backup_actions(self, **kwargs: Any) -> None: - """Perform pre backup actions.""" - if not self.loaded_platforms: - await self.load_platforms() + @callback + def _async_add_backup_agent_platform( + self, + integration_domain: str, + platform: BackupAgentPlatformProtocol, + ) -> None: + """Add backup agent platform to the backup manager.""" + if not hasattr(platform, "async_get_backup_agents"): + return + self.backup_agent_platforms[integration_domain] = platform + + @callback + def listener() -> None: + LOGGER.debug("Loading backup agents for %s", integration_domain) + self.hass.async_create_task( + self._async_reload_backup_agents(integration_domain) + ) + + if hasattr(platform, "async_register_backup_agents_listener"): + platform.async_register_backup_agents_listener(self.hass, listener=listener) + + listener() + + async def _async_reload_backup_agents(self, domain: str) -> None: + """Add backup agent platform to the backup manager.""" + platform = self.backup_agent_platforms[domain] + + # Remove all agents for the domain + for agent_id in list(self.backup_agents): + if self.backup_agents[agent_id].domain == domain: + del self.backup_agents[agent_id] + for agent_id in list(self.local_backup_agents): + if self.local_backup_agents[agent_id].domain == domain: + del self.local_backup_agents[agent_id] + + # Add new agents + agents = await platform.async_get_backup_agents(self.hass) + self.backup_agents.update({agent.agent_id: agent for agent in agents}) + self.local_backup_agents.update( + { + agent.agent_id: agent + for agent in agents + if isinstance(agent, LocalBackupAgent) + } + ) + + async def _add_platform( + self, + hass: HomeAssistant, + integration_domain: str, + platform: Any, + ) -> None: + """Add a backup platform manager.""" + self._add_platform_pre_post_handler(integration_domain, platform) + self._async_add_backup_agent_platform(integration_domain, platform) + LOGGER.debug("Backup platform %s loaded", integration_domain) + LOGGER.debug("%s platforms loaded in total", len(self.platforms)) + LOGGER.debug("%s agents loaded in total", len(self.backup_agents)) + LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents)) + + async def async_pre_backup_actions(self) -> None: + """Perform pre backup actions.""" pre_backup_results = await asyncio.gather( *( platform.async_pre_backup(self.hass) @@ -104,11 +366,8 @@ class BaseBackupManager(abc.ABC): if isinstance(result, Exception): raise result - async def async_post_backup_actions(self, **kwargs: Any) -> None: + async def async_post_backup_actions(self) -> None: """Perform post backup actions.""" - if not self.loaded_platforms: - await self.load_platforms() - post_backup_results = await asyncio.gather( *( platform.async_post_backup(self.hass) @@ -123,226 +382,703 @@ class BaseBackupManager(abc.ABC): async def load_platforms(self) -> None: """Load backup platforms.""" await integration_platform.async_process_integration_platforms( - self.hass, DOMAIN, self._add_platform, wait_for_platforms=True + self.hass, + DOMAIN, + self._add_platform, + wait_for_platforms=True, ) LOGGER.debug("Loaded %s platforms", len(self.platforms)) - self.loaded_platforms = True + LOGGER.debug("Loaded %s agents", len(self.backup_agents)) - @abc.abstractmethod - async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: - """Restore a backup.""" + async def _async_upload_backup( + self, + *, + backup: AgentBackup, + agent_ids: list[str], + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + ) -> dict[str, Exception]: + """Upload a backup to selected agents.""" + agent_errors: dict[str, Exception] = {} - @abc.abstractmethod - async def async_create_backup(self, **kwargs: Any) -> Backup: - """Generate a backup.""" + LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids) - @abc.abstractmethod - async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: + sync_backup_results = await asyncio.gather( + *( + self.backup_agents[agent_id].async_upload_backup( + open_stream=open_stream, + backup=backup, + ) + for agent_id in agent_ids + ), + return_exceptions=True, + ) + for idx, result in enumerate(sync_backup_results): + if isinstance(result, Exception): + agent_errors[agent_ids[idx]] = result + LOGGER.exception( + "Error during backup upload - %s", result, exc_info=result + ) + return agent_errors + + async def async_get_backups( + self, + ) -> tuple[dict[str, ManagerBackup], dict[str, Exception]]: """Get backups. - Return a dictionary of Backup instances keyed by their slug. + Return a dictionary of Backup instances keyed by their ID. """ + backups: dict[str, ManagerBackup] = {} + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents) - @abc.abstractmethod - async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: + list_backups_results = await asyncio.gather( + *(agent.async_list_backups() for agent in self.backup_agents.values()), + return_exceptions=True, + ) + for idx, result in enumerate(list_backups_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result + for agent_backup in result: + if (backup_id := agent_backup.backup_id) not in backups: + if known_backup := self.known_backups.get(backup_id): + failed_agent_ids = known_backup.failed_agent_ids + with_strategy_settings = known_backup.with_strategy_settings + else: + failed_agent_ids = [] + with_strategy_settings = False + backups[backup_id] = ManagerBackup( + agent_ids=[], + addons=agent_backup.addons, + backup_id=backup_id, + date=agent_backup.date, + database_included=agent_backup.database_included, + failed_agent_ids=failed_agent_ids, + folders=agent_backup.folders, + homeassistant_included=agent_backup.homeassistant_included, + homeassistant_version=agent_backup.homeassistant_version, + name=agent_backup.name, + protected=agent_backup.protected, + size=agent_backup.size, + with_strategy_settings=with_strategy_settings, + ) + backups[backup_id].agent_ids.append(agent_ids[idx]) + + return (backups, agent_errors) + + async def async_get_backup( + self, backup_id: str + ) -> tuple[ManagerBackup | None, dict[str, Exception]]: """Get a backup.""" + backup: ManagerBackup | None = None + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents) - @abc.abstractmethod - async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: - """Remove a backup.""" + get_backup_results = await asyncio.gather( + *( + agent.async_get_backup(backup_id) + for agent in self.backup_agents.values() + ), + return_exceptions=True, + ) + for idx, result in enumerate(get_backup_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result + if not result: + continue + if backup is None: + if known_backup := self.known_backups.get(backup_id): + failed_agent_ids = known_backup.failed_agent_ids + with_strategy_settings = known_backup.with_strategy_settings + else: + failed_agent_ids = [] + with_strategy_settings = False + backup = ManagerBackup( + agent_ids=[], + addons=result.addons, + backup_id=result.backup_id, + date=result.date, + database_included=result.database_included, + failed_agent_ids=failed_agent_ids, + folders=result.folders, + homeassistant_included=result.homeassistant_included, + homeassistant_version=result.homeassistant_version, + name=result.name, + protected=result.protected, + size=result.size, + with_strategy_settings=with_strategy_settings, + ) + backup.agent_ids.append(agent_ids[idx]) + + return (backup, agent_errors) + + async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]: + """Delete a backup.""" + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents) + + delete_backup_results = await asyncio.gather( + *( + agent.async_delete_backup(backup_id) + for agent in self.backup_agents.values() + ), + return_exceptions=True, + ) + for idx, result in enumerate(delete_backup_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result + + if not agent_errors: + self.known_backups.remove(backup_id) + + return agent_errors - @abc.abstractmethod async def async_receive_backup( self, *, + agent_ids: list[str], contents: aiohttp.BodyPartReader, - **kwargs: Any, ) -> None: """Receive and store a backup file from upload.""" + if self.state is not BackupManagerState.IDLE: + raise HomeAssistantError(f"Backup manager busy: {self.state}") + self.async_on_backup_event( + ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) + ) + try: + await self._async_receive_backup(agent_ids=agent_ids, contents=contents) + except Exception: + self.async_on_backup_event( + ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED) + ) + raise + else: + self.async_on_backup_event( + ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED) + ) + finally: + self.async_on_backup_event(IdleEvent()) + + async def _async_receive_backup( + self, + *, + agent_ids: list[str], + contents: aiohttp.BodyPartReader, + ) -> None: + """Receive and store a backup file from upload.""" + contents.chunk_size = BUF_SIZE + self.async_on_backup_event( + ReceiveBackupEvent( + stage=ReceiveBackupStage.RECEIVE_FILE, + state=ReceiveBackupState.IN_PROGRESS, + ) + ) + written_backup = await self._reader_writer.async_receive_backup( + agent_ids=agent_ids, + stream=contents, + suggested_filename=contents.filename or "backup.tar", + ) + self.async_on_backup_event( + ReceiveBackupEvent( + stage=ReceiveBackupStage.UPLOAD_TO_AGENTS, + state=ReceiveBackupState.IN_PROGRESS, + ) + ) + agent_errors = await self._async_upload_backup( + backup=written_backup.backup, + agent_ids=agent_ids, + open_stream=written_backup.open_stream, + ) + await written_backup.release_stream() + self.known_backups.add(written_backup.backup, agent_errors, False) + + async def async_create_backup( + self, + *, + agent_ids: list[str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + name: str | None, + password: str | None, + with_strategy_settings: bool = False, + ) -> NewBackup: + """Create a backup.""" + new_backup = await self.async_initiate_backup( + agent_ids=agent_ids, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + name=name, + password=password, + with_strategy_settings=with_strategy_settings, + ) + assert self._backup_finish_task + await self._backup_finish_task + return new_backup + + async def async_initiate_backup( + self, + *, + agent_ids: list[str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + name: str | None, + password: str | None, + with_strategy_settings: bool = False, + ) -> NewBackup: + """Initiate generating a backup.""" + if self.state is not BackupManagerState.IDLE: + raise HomeAssistantError(f"Backup manager busy: {self.state}") + + if with_strategy_settings: + self.config.data.last_attempted_strategy_backup = dt_util.now() + self.store.save() + + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + ) + try: + return await self._async_create_backup( + agent_ids=agent_ids, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + name=name, + password=password, + with_strategy_settings=with_strategy_settings, + ) + except Exception: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + self.async_on_backup_event(IdleEvent()) + raise + + async def _async_create_backup( + self, + *, + agent_ids: list[str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + name: str | None, + password: str | None, + with_strategy_settings: bool, + ) -> NewBackup: + """Initiate generating a backup.""" + if not agent_ids: + raise HomeAssistantError("At least one agent must be selected") + if any(agent_id not in self.backup_agents for agent_id in agent_ids): + raise HomeAssistantError("Invalid agent selected") + if include_all_addons and include_addons: + raise HomeAssistantError( + "Cannot include all addons and specify specific addons" + ) + + backup_name = name or f"Core {HAVERSION}" + new_backup, self._backup_task = await self._reader_writer.async_create_backup( + agent_ids=agent_ids, + backup_name=backup_name, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + on_progress=self.async_on_backup_event, + password=password, + ) + self._backup_finish_task = self.hass.async_create_task( + self._async_finish_backup(agent_ids, with_strategy_settings), + name="backup_manager_finish_backup", + ) + return new_backup + + async def _async_finish_backup( + self, agent_ids: list[str], with_strategy_settings: bool + ) -> None: + if TYPE_CHECKING: + assert self._backup_task is not None + try: + written_backup = await self._backup_task + except Exception as err: # noqa: BLE001 + LOGGER.debug("Generating backup failed", exc_info=err) + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + else: + LOGGER.debug( + "Generated new backup with backup_id %s, uploading to agents %s", + written_backup.backup.backup_id, + agent_ids, + ) + self.async_on_backup_event( + CreateBackupEvent( + stage=CreateBackupStage.UPLOAD_TO_AGENTS, + state=CreateBackupState.IN_PROGRESS, + ) + ) + agent_errors = await self._async_upload_backup( + backup=written_backup.backup, + agent_ids=agent_ids, + open_stream=written_backup.open_stream, + ) + await written_backup.release_stream() + if with_strategy_settings: + # create backup was successful, update last_completed_strategy_backup + self.config.data.last_completed_strategy_backup = dt_util.now() + self.store.save() + self.known_backups.add( + written_backup.backup, agent_errors, with_strategy_settings + ) + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED) + ) + finally: + self._backup_task = None + self._backup_finish_task = None + self.async_on_backup_event(IdleEvent()) + + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Initiate restoring a backup.""" + if self.state is not BackupManagerState.IDLE: + raise HomeAssistantError(f"Backup manager busy: {self.state}") + + self.async_on_backup_event( + RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) + ) + try: + await self._async_restore_backup( + backup_id=backup_id, + agent_id=agent_id, + password=password, + restore_addons=restore_addons, + restore_database=restore_database, + restore_folders=restore_folders, + restore_homeassistant=restore_homeassistant, + ) + except Exception: + self.async_on_backup_event( + RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED) + ) + raise + finally: + self.async_on_backup_event(IdleEvent()) + + async def _async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Initiate restoring a backup.""" + agent = self.backup_agents[agent_id] + if not await agent.async_get_backup(backup_id): + raise HomeAssistantError( + f"Backup {backup_id} not found in agent {agent_id}" + ) + + async def open_backup() -> AsyncIterator[bytes]: + return await agent.async_download_backup(backup_id) + + await self._reader_writer.async_restore_backup( + backup_id=backup_id, + open_stream=open_backup, + agent_id=agent_id, + password=password, + restore_addons=restore_addons, + restore_database=restore_database, + restore_folders=restore_folders, + restore_homeassistant=restore_homeassistant, + ) + + @callback + def async_on_backup_event( + self, + event: ManagerStateEvent, + ) -> None: + """Forward event to subscribers.""" + if (current_state := self.state) != (new_state := event.manager_state): + LOGGER.debug("Backup state: %s -> %s", current_state, new_state) + self.last_event = event + for subscription in self._backup_event_subscriptions: + subscription(event) + + @callback + def async_subscribe_events( + self, + on_event: Callable[[ManagerStateEvent], None], + ) -> Callable[[], None]: + """Subscribe events.""" + + def remove_subscription() -> None: + self._backup_event_subscriptions.remove(on_event) + + self._backup_event_subscriptions.append(on_event) + return remove_subscription -class BackupManager(BaseBackupManager): - """Backup manager for the Backup integration.""" +class KnownBackups: + """Track known backups.""" + + def __init__(self, manager: BackupManager) -> None: + """Initialize.""" + self._backups: dict[str, KnownBackup] = {} + self._manager = manager + + def load(self, stored_backups: list[StoredKnownBackup]) -> None: + """Load backups.""" + self._backups = { + backup["backup_id"]: KnownBackup( + backup_id=backup["backup_id"], + failed_agent_ids=backup["failed_agent_ids"], + with_strategy_settings=backup["with_strategy_settings"], + ) + for backup in stored_backups + } + + def to_list(self) -> list[StoredKnownBackup]: + """Convert known backups to a dict.""" + return [backup.to_dict() for backup in self._backups.values()] + + def add( + self, + backup: AgentBackup, + agent_errors: dict[str, Exception], + with_strategy_settings: bool, + ) -> None: + """Add a backup.""" + self._backups[backup.backup_id] = KnownBackup( + backup_id=backup.backup_id, + failed_agent_ids=list(agent_errors), + with_strategy_settings=with_strategy_settings, + ) + self._manager.store.save() + + def get(self, backup_id: str) -> KnownBackup | None: + """Get a backup.""" + return self._backups.get(backup_id) + + def remove(self, backup_id: str) -> None: + """Remove a backup.""" + if backup_id not in self._backups: + return + self._backups.pop(backup_id) + self._manager.store.save() + + +@dataclass(kw_only=True) +class KnownBackup: + """Persistent backup data.""" + + backup_id: str + failed_agent_ids: list[str] + with_strategy_settings: bool + + def to_dict(self) -> StoredKnownBackup: + """Convert known backup to a dict.""" + return { + "backup_id": self.backup_id, + "failed_agent_ids": self.failed_agent_ids, + "with_strategy_settings": self.with_strategy_settings, + } + + +class StoredKnownBackup(TypedDict): + """Stored persistent backup data.""" + + backup_id: str + failed_agent_ids: list[str] + with_strategy_settings: bool + + +class CoreBackupReaderWriter(BackupReaderWriter): + """Class for reading and writing backups in core and container installations.""" + + _local_agent_id = f"{DOMAIN}.local" def __init__(self, hass: HomeAssistant) -> None: - """Initialize the backup manager.""" - super().__init__(hass=hass) - self.backup_dir = Path(hass.config.path("backups")) - self.loaded_backups = False + """Initialize the backup reader/writer.""" + self._hass = hass + self.temp_backup_dir = Path(hass.config.path("tmp_backups")) - async def load_backups(self) -> None: - """Load data of stored backup files.""" - backups = await self.hass.async_add_executor_job(self._read_backups) - LOGGER.debug("Loaded %s backups", len(backups)) - self.backups = backups - self.loaded_backups = True - - def _read_backups(self) -> dict[str, Backup]: - """Read backups from disk.""" - backups: dict[str, Backup] = {} - for backup_path in self.backup_dir.glob("*.tar"): - try: - with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: - if data_file := backup_file.extractfile("./backup.json"): - data = json_loads_object(data_file.read()) - backup = Backup( - slug=cast(str, data["slug"]), - name=cast(str, data["name"]), - date=cast(str, data["date"]), - path=backup_path, - size=round(backup_path.stat().st_size / 1_048_576, 2), - ) - backups[backup.slug] = backup - except (OSError, TarError, json.JSONDecodeError, KeyError) as err: - LOGGER.warning("Unable to read backup %s: %s", backup_path, err) - return backups - - async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: - """Return backups.""" - if not self.loaded_backups: - await self.load_backups() - - return self.backups - - async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: - """Return a backup.""" - if not self.loaded_backups: - await self.load_backups() - - if not (backup := self.backups.get(slug)): - return None - - if not backup.path.exists(): - LOGGER.debug( - ( - "Removing tracked backup (%s) that does not exists on the expected" - " path %s" - ), - backup.slug, - backup.path, - ) - self.backups.pop(slug) - return None - - return backup - - async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: - """Remove a backup.""" - if (backup := await self.async_get_backup(slug=slug)) is None: - return - - await self.hass.async_add_executor_job(backup.path.unlink, True) - LOGGER.debug("Removed backup located at %s", backup.path) - self.backups.pop(slug) - - async def async_receive_backup( + async def async_create_backup( self, *, - contents: aiohttp.BodyPartReader, - **kwargs: Any, - ) -> None: - """Receive and store a backup file from upload.""" - queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = ( - SimpleQueue() - ) - temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory) - target_temp_file = Path( - temp_dir_handler.name, contents.filename or "backup.tar" + agent_ids: list[str], + backup_name: str, + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + on_progress: Callable[[ManagerStateEvent], None], + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: + """Initiate generating a backup.""" + date_str = dt_util.now().isoformat() + backup_id = _generate_backup_id(date_str, backup_name) + + if include_addons or include_all_addons or include_folders: + raise HomeAssistantError( + "Addons and folders are not supported by core backup" + ) + if not include_homeassistant: + raise HomeAssistantError("Home Assistant must be included in backup") + + backup_task = self._hass.async_create_task( + self._async_create_backup( + agent_ids=agent_ids, + backup_id=backup_id, + backup_name=backup_name, + include_database=include_database, + date_str=date_str, + on_progress=on_progress, + password=password, + ), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return ) - def _sync_queue_consumer() -> None: - with target_temp_file.open("wb") as file_handle: - while True: - if (_chunk_future := queue.get()) is None: - break - _chunk, _future = _chunk_future - if _future is not None: - self.hass.loop.call_soon_threadsafe(_future.set_result, None) - file_handle.write(_chunk) + return (NewBackup(backup_job_id=backup_id), backup_task) - fut: asyncio.Future[None] | None = None - try: - fut = self.hass.async_add_executor_job(_sync_queue_consumer) - megabytes_sending = 0 - while chunk := await contents.read_chunk(BUF_SIZE): - megabytes_sending += 1 - if megabytes_sending % 5 != 0: - queue.put_nowait((chunk, None)) - continue - - chunk_future = self.hass.loop.create_future() - queue.put_nowait((chunk, chunk_future)) - await asyncio.wait( - (fut, chunk_future), - return_when=asyncio.FIRST_COMPLETED, - ) - if fut.done(): - # The executor job failed - break - - queue.put_nowait(None) # terminate queue consumer - finally: - if fut is not None: - await fut - - def _move_and_cleanup() -> None: - shutil.move(target_temp_file, self.backup_dir / target_temp_file.name) - temp_dir_handler.cleanup() - - await self.hass.async_add_executor_job(_move_and_cleanup) - await self.load_backups() - - async def async_create_backup(self, **kwargs: Any) -> Backup: + async def _async_create_backup( + self, + *, + agent_ids: list[str], + backup_id: str, + backup_name: str, + date_str: str, + include_database: bool, + on_progress: Callable[[ManagerStateEvent], None], + password: str | None, + ) -> WrittenBackup: """Generate a backup.""" - if self.backing_up: - raise HomeAssistantError("Backup already in progress") + manager = self._hass.data[DATA_MANAGER] + local_agent_tar_file_path = None + if self._local_agent_id in agent_ids: + local_agent = manager.local_backup_agents[self._local_agent_id] + local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + + on_progress( + CreateBackupEvent( + stage=CreateBackupStage.HOME_ASSISTANT, + state=CreateBackupState.IN_PROGRESS, + ) + ) try: - self.backing_up = True - await self.async_pre_backup_actions() - backup_name = f"Core {HAVERSION}" - date_str = dt_util.now().isoformat() - slug = _generate_slug(date_str, backup_name) + # Inform integrations a backup is about to be made + await manager.async_pre_backup_actions() backup_data = { - "slug": slug, - "name": backup_name, - "date": date_str, - "type": "partial", - "folders": ["homeassistant"], - "homeassistant": {"version": HAVERSION}, "compressed": True, + "date": date_str, + "homeassistant": { + "exclude_database": not include_database, + "version": HAVERSION, + }, + "name": backup_name, + "protected": password is not None, + "slug": backup_id, + "type": "partial", + "version": 2, } - tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") - size_in_bytes = await self.hass.async_add_executor_job( + + tar_file_path, size_in_bytes = await self._hass.async_add_executor_job( self._mkdir_and_generate_backup_contents, - tar_file_path, backup_data, + include_database, + password, + local_agent_tar_file_path, ) - backup = Backup( - slug=slug, - name=backup_name, + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, date=date_str, - path=tar_file_path, - size=round(size_in_bytes / 1_048_576, 2), + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=size_in_bytes, + ) + + async_add_executor_job = self._hass.async_add_executor_job + + async def send_backup() -> AsyncIterator[bytes]: + f = await async_add_executor_job(tar_file_path.open, "rb") + try: + while chunk := await async_add_executor_job(f.read, 2**20): + yield chunk + finally: + await async_add_executor_job(f.close) + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + async def remove_backup() -> None: + if local_agent_tar_file_path: + return + await async_add_executor_job(tar_file_path.unlink, True) + + return WrittenBackup( + backup=backup, open_stream=open_backup, release_stream=remove_backup ) - if self.loaded_backups: - self.backups[slug] = backup - LOGGER.debug("Generated new backup with slug %s", slug) - return backup finally: - self.backing_up = False - await self.async_post_backup_actions() + # Inform integrations the backup is done + await manager.async_post_backup_actions() def _mkdir_and_generate_backup_contents( self, - tar_file_path: Path, backup_data: dict[str, Any], - ) -> int: + database_included: bool, + password: str | None, + tar_file_path: Path | None, + ) -> tuple[Path, int]: """Generate backup contents and return the size.""" - if not self.backup_dir.exists(): - LOGGER.debug("Creating backup directory") - self.backup_dir.mkdir() + if not tar_file_path: + tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar" + make_backup_dir(tar_file_path.parent) + + excludes = EXCLUDE_FROM_BACKUP + if not database_included: + excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE @@ -355,37 +1091,136 @@ class BackupManager(BaseBackupManager): tar_info.mtime = int(time.time()) outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj) with outer_secure_tarfile.create_inner_tar( - "./homeassistant.tar.gz", gzip=True + "./homeassistant.tar.gz", + gzip=True, + key=password_to_key(password) if password is not None else None, ) as core_tar: atomic_contents_add( tar_file=core_tar, - origin_path=Path(self.hass.config.path()), - excludes=EXCLUDE_FROM_BACKUP, + origin_path=Path(self._hass.config.path()), + excludes=excludes, arcname="data", ) + return (tar_file_path, tar_file_path.stat().st_size) - return tar_file_path.stat().st_size + async def async_receive_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + suggested_filename: str, + ) -> WrittenBackup: + """Receive a backup.""" + temp_file = Path(self.temp_backup_dir, suggested_filename) - async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: + async_add_executor_job = self._hass.async_add_executor_job + await async_add_executor_job(make_backup_dir, self.temp_backup_dir) + f = await async_add_executor_job(temp_file.open, "wb") + try: + async for chunk in stream: + await async_add_executor_job(f.write, chunk) + finally: + await async_add_executor_job(f.close) + + try: + backup = await async_add_executor_job(read_backup, temp_file) + except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err: + LOGGER.warning("Unable to parse backup %s: %s", temp_file, err) + raise + + manager = self._hass.data[DATA_MANAGER] + if self._local_agent_id in agent_ids: + local_agent = manager.local_backup_agents[self._local_agent_id] + tar_file_path = local_agent.get_backup_path(backup.backup_id) + await async_add_executor_job(shutil.move, temp_file, tar_file_path) + else: + tar_file_path = temp_file + + async def send_backup() -> AsyncIterator[bytes]: + f = await async_add_executor_job(tar_file_path.open, "rb") + try: + while chunk := await async_add_executor_job(f.read, 2**20): + yield chunk + finally: + await async_add_executor_job(f.close) + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + async def remove_backup() -> None: + if self._local_agent_id in agent_ids: + return + await async_add_executor_job(temp_file.unlink, True) + + return WrittenBackup( + backup=backup, open_stream=open_backup, release_stream=remove_backup + ) + + async def async_restore_backup( + self, + backup_id: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + *, + agent_id: str, + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: """Restore a backup. This will write the restore information to .HA_RESTORE which will be handled during startup by the restore_backup module. """ - if (backup := await self.async_get_backup(slug=slug)) is None: - raise HomeAssistantError(f"Backup {slug} not found") + + if restore_addons or restore_folders: + raise HomeAssistantError( + "Addons and folders are not supported in core restore" + ) + if not restore_homeassistant and not restore_database: + raise HomeAssistantError( + "Home Assistant or database must be included in restore" + ) + + manager = self._hass.data[DATA_MANAGER] + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + remove_after_restore = False + else: + async_add_executor_job = self._hass.async_add_executor_job + path = self.temp_backup_dir / f"{backup_id}.tar" + stream = await open_stream() + await async_add_executor_job(make_backup_dir, self.temp_backup_dir) + f = await async_add_executor_job(path.open, "wb") + try: + async for chunk in stream: + await async_add_executor_job(f.write, chunk) + finally: + await async_add_executor_job(f.close) + + remove_after_restore = True def _write_restore_file() -> None: """Write the restore file.""" - Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( - json.dumps({"path": backup.path.as_posix()}), + Path(self._hass.config.path(RESTORE_BACKUP_FILE)).write_text( + json.dumps( + { + "path": path.as_posix(), + "password": password, + "remove_after_restore": remove_after_restore, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + ), encoding="utf-8", ) - await self.hass.async_add_executor_job(_write_restore_file) - await self.hass.services.async_call("homeassistant", "restart", {}) + await self._hass.async_add_executor_job(_write_restore_file) + await self._hass.services.async_call("homeassistant", "restart", {}) -def _generate_slug(date: str, name: str) -> str: - """Generate a backup slug.""" +def _generate_backup_id(date: str, name: str) -> str: + """Generate a backup ID.""" return hashlib.sha1(f"{date} - {name}".lower().encode()).hexdigest()[:8] diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 0a906bb6dfa..b399043e013 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -1,11 +1,12 @@ { "domain": "backup", "name": "Backup", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2024.11.0"] + "requirements": ["cronsim==2.6", "securetar==2024.11.0"] } diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py new file mode 100644 index 00000000000..6306d9f1fec --- /dev/null +++ b/homeassistant/components/backup/models.py @@ -0,0 +1,61 @@ +"""Models for the backup integration.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import StrEnum +from typing import Any, Self + + +@dataclass(frozen=True, kw_only=True) +class AddonInfo: + """Addon information.""" + + name: str + slug: str + version: str + + +class Folder(StrEnum): + """Folder type.""" + + SHARE = "share" + ADDONS = "addons/local" + SSL = "ssl" + MEDIA = "media" + + +@dataclass(frozen=True, kw_only=True) +class AgentBackup: + """Base backup class.""" + + addons: list[AddonInfo] + backup_id: str + date: str + database_included: bool + folders: list[Folder] + homeassistant_included: bool + homeassistant_version: str | None # None if homeassistant_included is False + name: str + protected: bool + size: int + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Create an instance from a JSON serialization.""" + return cls( + addons=[AddonInfo(**addon) for addon in data["addons"]], + backup_id=data["backup_id"], + date=data["date"], + database_included=data["database_included"], + folders=[Folder(folder) for folder in data["folders"]], + homeassistant_included=data["homeassistant_included"], + homeassistant_version=data["homeassistant_version"], + name=data["name"], + protected=data["protected"], + size=data["size"], + ) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py new file mode 100644 index 00000000000..ddabead24f9 --- /dev/null +++ b/homeassistant/components/backup/store.py @@ -0,0 +1,52 @@ +"""Store backup configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +if TYPE_CHECKING: + from .config import StoredBackupConfig + from .manager import BackupManager, StoredKnownBackup + +STORE_DELAY_SAVE = 30 +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + + +class StoredBackupData(TypedDict): + """Represent the stored backup config.""" + + backups: list[StoredKnownBackup] + config: StoredBackupConfig + + +class BackupStore: + """Store backup config.""" + + def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: + """Initialize the backup manager.""" + self._hass = hass + self._manager = manager + self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + + async def load(self) -> StoredBackupData | None: + """Load the store.""" + return await self._store.async_load() + + @callback + def save(self) -> None: + """Save config.""" + self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE) + + @callback + def _data_to_save(self) -> StoredBackupData: + """Return data to save.""" + return { + "backups": self._manager.known_backups.to_list(), + "config": self._manager.config.data.to_dict(), + } diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py new file mode 100644 index 00000000000..1d8252cc30b --- /dev/null +++ b/homeassistant/components/backup/util.py @@ -0,0 +1,111 @@ +"""Local backup support for Core and Container installations.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from queue import SimpleQueue +import tarfile +from typing import cast + +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType, json_loads_object + +from .const import BUF_SIZE +from .models import AddonInfo, AgentBackup, Folder + + +def make_backup_dir(path: Path) -> None: + """Create a backup directory if it does not exist.""" + path.mkdir(exist_ok=True) + + +def read_backup(backup_path: Path) -> AgentBackup: + """Read a backup from disk.""" + + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: + if not (data_file := backup_file.extractfile("./backup.json")): + raise KeyError("backup.json not found in tar file") + data = json_loads_object(data_file.read()) + addons = [ + AddonInfo( + name=cast(str, addon["name"]), + slug=cast(str, addon["slug"]), + version=cast(str, addon["version"]), + ) + for addon in cast(list[JsonObjectType], data.get("addons", [])) + ] + + folders = [ + Folder(folder) + for folder in cast(list[str], data.get("folders", [])) + if folder != "homeassistant" + ] + + homeassistant_included = False + homeassistant_version: str | None = None + database_included = False + if ( + homeassistant := cast(JsonObjectType, data.get("homeassistant")) + ) and "version" in homeassistant: + homeassistant_version = cast(str, homeassistant["version"]) + database_included = not cast( + bool, homeassistant.get("exclude_database", False) + ) + + return AgentBackup( + addons=addons, + backup_id=cast(str, data["slug"]), + database_included=database_included, + date=cast(str, data["date"]), + folders=folders, + homeassistant_included=homeassistant_included, + homeassistant_version=homeassistant_version, + name=cast(str, data["name"]), + protected=cast(bool, data.get("protected", False)), + size=backup_path.stat().st_size, + ) + + +async def receive_file( + hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path +) -> None: + """Receive a file from a stream and write it to a file.""" + queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = SimpleQueue() + + def _sync_queue_consumer() -> None: + with path.open("wb") as file_handle: + while True: + if (_chunk_future := queue.get()) is None: + break + _chunk, _future = _chunk_future + if _future is not None: + hass.loop.call_soon_threadsafe(_future.set_result, None) + file_handle.write(_chunk) + + fut: asyncio.Future[None] | None = None + try: + fut = hass.async_add_executor_job(_sync_queue_consumer) + megabytes_sending = 0 + while chunk := await contents.read_chunk(BUF_SIZE): + megabytes_sending += 1 + if megabytes_sending % 5 != 0: + queue.put_nowait((chunk, None)) + continue + + chunk_future = hass.loop.create_future() + queue.put_nowait((chunk, chunk_future)) + await asyncio.wait( + (fut, chunk_future), + return_when=asyncio.FIRST_COMPLETED, + ) + if fut.done(): + # The executor job failed + break + + queue.put_nowait(None) # terminate queue consumer + finally: + if fut is not None: + await fut diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 3ac8a7ace3e..7dacc39f9ba 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -7,22 +7,31 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from .config import ScheduleState from .const import DATA_MANAGER, LOGGER +from .manager import ManagerStateEvent +from .models import Folder @callback def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None: """Register websocket commands.""" + websocket_api.async_register_command(hass, backup_agents_info) + if with_hassio: websocket_api.async_register_command(hass, handle_backup_end) websocket_api.async_register_command(hass, handle_backup_start) - return websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_info) websocket_api.async_register_command(hass, handle_create) - websocket_api.async_register_command(hass, handle_remove) + websocket_api.async_register_command(hass, handle_create_with_strategy_settings) + websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) + websocket_api.async_register_command(hass, handle_subscribe_events) + + websocket_api.async_register_command(hass, handle_config_info) + websocket_api.async_register_command(hass, handle_config_update) @websocket_api.require_admin @@ -35,12 +44,16 @@ async def handle_info( ) -> None: """List all stored backups.""" manager = hass.data[DATA_MANAGER] - backups = await manager.async_get_backups() + backups, agent_errors = await manager.async_get_backups() connection.send_result( msg["id"], { + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + }, "backups": list(backups.values()), - "backing_up": manager.backing_up, + "last_attempted_strategy_backup": manager.config.data.last_attempted_strategy_backup, + "last_completed_strategy_backup": manager.config.data.last_completed_strategy_backup, }, ) @@ -49,7 +62,7 @@ async def handle_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/details", - vol.Required("slug"): str, + vol.Required("backup_id"): str, } ) @websocket_api.async_response @@ -58,11 +71,16 @@ async def handle_details( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Get backup details for a specific slug.""" - backup = await hass.data[DATA_MANAGER].async_get_backup(slug=msg["slug"]) + """Get backup details for a specific backup.""" + backup, agent_errors = await hass.data[DATA_MANAGER].async_get_backup( + msg["backup_id"] + ) connection.send_result( msg["id"], { + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + }, "backup": backup, }, ) @@ -71,26 +89,39 @@ async def handle_details( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "backup/remove", - vol.Required("slug"): str, + vol.Required("type"): "backup/delete", + vol.Required("backup_id"): str, } ) @websocket_api.async_response -async def handle_remove( +async def handle_delete( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Remove a backup.""" - await hass.data[DATA_MANAGER].async_remove_backup(slug=msg["slug"]) - connection.send_result(msg["id"]) + """Delete a backup.""" + agent_errors = await hass.data[DATA_MANAGER].async_delete_backup(msg["backup_id"]) + connection.send_result( + msg["id"], + { + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + } + }, + ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "backup/restore", - vol.Required("slug"): str, + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + vol.Optional("restore_homeassistant", default=True): bool, } ) @websocket_api.async_response @@ -100,12 +131,32 @@ async def handle_restore( msg: dict[str, Any], ) -> None: """Restore a backup.""" - await hass.data[DATA_MANAGER].async_restore_backup(msg["slug"]) + await hass.data[DATA_MANAGER].async_restore_backup( + msg["backup_id"], + agent_id=msg["agent_id"], + password=msg.get("password"), + restore_addons=msg.get("restore_addons"), + restore_database=msg["restore_database"], + restore_folders=msg.get("restore_folders"), + restore_homeassistant=msg["restore_homeassistant"], + ) connection.send_result(msg["id"]) @websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/generate"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/generate", + vol.Required("agent_ids"): [str], + vol.Optional("include_addons"): [str], + vol.Optional("include_all_addons", default=False): bool, + vol.Optional("include_database", default=True): bool, + vol.Optional("include_folders"): [vol.Coerce(Folder)], + vol.Optional("include_homeassistant", default=True): bool, + vol.Optional("name"): str, + vol.Optional("password"): str, + } +) @websocket_api.async_response async def handle_create( hass: HomeAssistant, @@ -113,7 +164,46 @@ async def handle_create( msg: dict[str, Any], ) -> None: """Generate a backup.""" - backup = await hass.data[DATA_MANAGER].async_create_backup() + + backup = await hass.data[DATA_MANAGER].async_initiate_backup( + agent_ids=msg["agent_ids"], + include_addons=msg.get("include_addons"), + include_all_addons=msg["include_all_addons"], + include_database=msg["include_database"], + include_folders=msg.get("include_folders"), + include_homeassistant=msg["include_homeassistant"], + name=msg.get("name"), + password=msg.get("password"), + ) + connection.send_result(msg["id"], backup) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/generate_with_strategy_settings", + } +) +@websocket_api.async_response +async def handle_create_with_strategy_settings( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a backup with stored settings.""" + + config_data = hass.data[DATA_MANAGER].config.data + backup = await hass.data[DATA_MANAGER].async_initiate_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_strategy_settings=True, + ) connection.send_result(msg["id"], backup) @@ -127,7 +217,6 @@ async def handle_backup_start( ) -> None: """Backup start notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = True LOGGER.debug("Backup start notification") try: @@ -149,7 +238,6 @@ async def handle_backup_end( ) -> None: """Backup end notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = False LOGGER.debug("Backup end notification") try: @@ -159,3 +247,97 @@ async def handle_backup_end( return connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"}) +@websocket_api.async_response +async def backup_agents_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return backup agents info.""" + manager = hass.data[DATA_MANAGER] + connection.send_result( + msg["id"], + { + "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents], + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/config/info"}) +@websocket_api.async_response +async def handle_config_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send the stored backup config.""" + manager = hass.data[DATA_MANAGER] + connection.send_result( + msg["id"], + { + "config": manager.config.data.to_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/config/update", + vol.Optional("create_backup"): vol.Schema( + { + vol.Optional("agent_ids"): vol.All(list[str]), + vol.Optional("include_addons"): vol.Any(list[str], None), + vol.Optional("include_all_addons"): bool, + vol.Optional("include_database"): bool, + vol.Optional("include_folders"): vol.Any([vol.Coerce(Folder)], None), + vol.Optional("name"): vol.Any(str, None), + vol.Optional("password"): vol.Any(str, None), + }, + ), + vol.Optional("retention"): vol.Schema( + { + vol.Optional("copies"): vol.Any(int, None), + vol.Optional("days"): vol.Any(int, None), + }, + ), + vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)), + } +) +@websocket_api.async_response +async def handle_config_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update the stored backup config.""" + manager = hass.data[DATA_MANAGER] + changes = dict(msg) + changes.pop("id") + changes.pop("type") + await manager.config.update(**changes) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index 38407813d37..c30d49e8c9d 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -40,7 +40,6 @@ class BAFAutoComfort(BAFEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_translation_key = "auto_comfort" - _enable_turn_on_off_backwards_compatibility = False @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index d0ba668373a..8f7aab40b79 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -46,7 +46,7 @@ class BAFFan(BAFEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT _attr_name = None diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index 2fb36ed874f..4c0b1e353fe 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -8,16 +8,12 @@ from aiobafi6 import Device, OffOnAuto from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from . import BAFConfigEntry from .entity import BAFEntity @@ -77,25 +73,17 @@ class BAFStandaloneLight(BAFLight): def __init__(self, device: Device) -> None: """Init a standalone light.""" super().__init__(device) - self._attr_min_mireds = color_temperature_kelvin_to_mired( - device.light_warmest_color_temperature - ) - self._attr_max_mireds = color_temperature_kelvin_to_mired( - device.light_coolest_color_temperature - ) + self._attr_max_color_temp_kelvin = device.light_warmest_color_temperature + self._attr_min_color_temp_kelvin = device.light_coolest_color_temperature @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" super()._async_update_attrs() - self._attr_color_temp = color_temperature_kelvin_to_mired( - self._device.light_color_temperature - ) + self._attr_color_temp_kelvin = self._device.light_color_temperature async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: - self._device.light_color_temperature = color_temperature_mired_to_kelvin( - color_temp - ) + if (color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + self._device.light_color_temperature = color_temp await super().async_turn_on(**kwargs) diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index d27fd459676..76b02f0e165 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -65,7 +65,6 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): ) _attr_translation_key = DOMAIN _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py index 67c1d9a9a62..3ecfec53a1e 100644 --- a/homeassistant/components/balboa/fan.py +++ b/homeassistant/components/balboa/fan.py @@ -38,7 +38,7 @@ class BalboaPumpFanEntity(BalboaEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_translation_key = "pump" def __init__(self, control: SpaControl) -> None: diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index c8ba1f1c3dc..be99f8b5b7d 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -8,6 +8,7 @@ from aiohttp.client_exceptions import ( ClientConnectorError, ClientOSError, ServerTimeoutError, + WSMessageTypeError, ) from mozart_api.exceptions import ApiException from mozart_api.mozart_client import MozartClient @@ -62,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) ServerTimeoutError, ApiException, TimeoutError, + WSMessageTypeError, ) as error: await client.close_api_client() raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 209311d3e8a..9f0649e610b 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -210,3 +210,20 @@ BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" + +# Beolink Converter NL/ML sources need to be transformed to upper case +BEOLINK_JOIN_SOURCES_TO_UPPER = ( + "aux_a", + "cd", + "ph", + "radio", + "tp1", + "tp2", +) +BEOLINK_JOIN_SOURCES = ( + *BEOLINK_JOIN_SOURCES_TO_UPPER, + "beoradio", + "deezer", + "spotify", + "tidal", +) diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index 1565c98e979..b29fe9731de 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==4.1.1.116.3"], + "requirements": ["mozart-api==4.1.1.116.4"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 96e7cca0175..282ecdd2ae5 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -74,6 +74,8 @@ from .const import ( BANG_OLUFSEN_REPEAT_FROM_HA, BANG_OLUFSEN_REPEAT_TO_HA, BANG_OLUFSEN_STATES, + BEOLINK_JOIN_SOURCES, + BEOLINK_JOIN_SOURCES_TO_UPPER, CONF_BEOLINK_JID, CONNECTION_STATUS, DOMAIN, @@ -135,7 +137,10 @@ async def async_setup_entry( platform.async_register_entity_service( name="beolink_join", - schema={vol.Optional("beolink_jid"): jid_regex}, + schema={ + vol.Optional("beolink_jid"): jid_regex, + vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES), + }, func="async_beolink_join", ) @@ -985,12 +990,23 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): await self.async_beolink_leave() # Custom actions: - async def async_beolink_join(self, beolink_jid: str | None = None) -> None: + async def async_beolink_join( + self, beolink_jid: str | None = None, source_id: str | None = None + ) -> None: """Join a Beolink multi-room experience.""" + # Touch to join if beolink_jid is None: await self._client.join_latest_beolink_experience() - else: + # Join a peer + elif beolink_jid and source_id is None: await self._client.join_beolink_peer(jid=beolink_jid) + # Join a peer and select specific source + elif beolink_jid and source_id: + # Beolink Converter NL/ML sources need to be in upper case + if source_id in BEOLINK_JOIN_SOURCES_TO_UPPER: + source_id = source_id.upper() + + await self._client.join_beolink_peer(jid=beolink_jid, source=source_id) async def async_beolink_expand( self, beolink_jids: list[str] | None = None, all_discovered: bool = False diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml index e5d61420dff..7c3a2d659bd 100644 --- a/homeassistant/components/bang_olufsen/services.yaml +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -48,6 +48,23 @@ beolink_join: example: 1111.2222222.33333333@products.bang-olufsen.com selector: text: + source_id: + required: false + example: tidal + selector: + select: + translation_key: "source_ids" + options: + - beoradio + - deezer + - spotify + - tidal + - radio + - tp1 + - tp2 + - cd + - aux_a + - ph beolink_leave: target: diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 6e75d2f26c8..b4aac78756c 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -29,6 +29,22 @@ } } }, + "selector": { + "source_ids": { + "options": { + "beoradio": "ASE Beoradio", + "deezer": "ASE / Mozart Deezer", + "spotify": "ASE / Mozart Spotify", + "tidal": "Mozart Tidal", + "aux_a": "Beolink Converter NL/ML AUX_A", + "cd": "Beolink Converter NL/ML CD", + "ph": "Beolink Converter NL/ML PH", + "radio": "Beolink Converter NL/ML RADIO", + "tp1": "Beolink Converter NL/ML TP1", + "tp2": "Beolink Converter NL/ML TP2" + } + } + }, "services": { "beolink_allstandby": { "name": "Beolink all standby", @@ -61,6 +77,10 @@ "beolink_jid": { "name": "Beolink JID", "description": "Manually specify Beolink JID to join." + }, + "source_id": { + "name": "Source", + "description": "Specify which source to join, behavior varies between hardware platforms. Source names prefaced by a platform name can only be used when connecting to that platform. For example \"ASE Beoradio\" can only be used when joining an ASE device, while ”ASE / Mozart Deezer” can be used with ASE or Mozart devices. A defined Beolink JID is required." } }, "sections": { diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index e04503974b7..2c528d50e3e 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -57,7 +57,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False @property def hvac_modes(self): diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 33fff1d71da..c3c9de8be51 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -11,7 +11,7 @@ from blebox_uniapi.light import BleboxColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -22,6 +22,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -58,8 +59,8 @@ COLOR_MODE_MAP = { class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): """Representation of BleBox lights.""" - _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds - _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 154 Mireds def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" @@ -78,9 +79,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): return self._feature.brightness @property - def color_temp(self): - """Return color temperature.""" - return self._feature.color_temp + def color_temp_kelvin(self) -> int: + """Return the color temperature value in Kelvin.""" + return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp) @property def color_mode(self): @@ -136,7 +137,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgbww = kwargs.get(ATTR_RGBWW_COLOR) feature = self._feature value = feature.sensible_on_value @@ -144,9 +145,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): if rgbw is not None: value = list(rgbw) - if color_temp is not None: + if color_temp_kelvin is not None: value = feature.return_color_temp_with_brightness( - int(color_temp), self.brightness + int(color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)), + self.brightness, ) if rgbww is not None: @@ -158,9 +160,12 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): value = list(rgb) if brightness is not None: - if self.color_mode == ATTR_COLOR_TEMP: + if self.color_mode == ColorMode.COLOR_TEMP: value = feature.return_color_temp_with_brightness( - self.color_temp, brightness + color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ), + brightness, ) else: value = feature.apply_brightness(value, brightness) diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 5f51598e721..dd5d1e37627 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,7 +5,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN +from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -13,11 +13,6 @@ from homeassistant.helpers import config_validation as cv from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkConfigEntry -SERVICE_UPDATE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - } -) SERVICE_SEND_PIN_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 9e43cfc4187..7b6fb4119db 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass import logging import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -18,7 +16,7 @@ from homeassistant.helpers import ( import homeassistant.helpers.config_validation as cv from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN -from .coordinator import BMWDataUpdateCoordinator +from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,19 +47,9 @@ PLATFORMS = [ SERVICE_UPDATE_STATE = "update_state" -type BMWConfigEntry = ConfigEntry[BMWData] - - -@dataclass -class BMWData: - """Class to store BMW runtime data.""" - - coordinator: BMWDataUpdateCoordinator - - @callback def _async_migrate_options_from_data_if_missing( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: BMWConfigEntry ) -> None: data = dict(entry.data) options = dict(entry.options) @@ -127,7 +115,7 @@ async def _async_migrate_entries( return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool: """Set up BMW Connected Drive from a config entry.""" _async_migrate_options_from_data_if_missing(hass, entry) @@ -137,11 +125,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up one data coordinator per account/config entry coordinator = BMWDataUpdateCoordinator( hass, - entry=entry, + config_entry=entry, ) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = BMWData(coordinator) + entry.runtime_data = coordinator # Set up all platforms except notify await hass.config_entries.async_forward_entry_setups( @@ -175,7 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 285ac98fc8f..5a58c707d6a 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -203,7 +203,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW binary sensors from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities = [ BMWBinarySensor(coordinator, vehicle, description, hass.config.units) diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 85747278cb1..a7c31d0ef79 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 AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .entity import BMWBaseEntity if TYPE_CHECKING: @@ -55,7 +55,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( BMWButtonEntityDescription( key="deactivate_air_conditioning", translation_key="deactivate_air_conditioning", - name="Deactivate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, ), @@ -73,7 +72,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW buttons from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWButton] = [] @@ -111,6 +110,10 @@ class BMWButton(BMWBaseEntity, ButtonEntity): try: await self.entity_description.remote_function(self.vehicle) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 8831895c71e..04fb3842dfa 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -18,7 +18,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -39,6 +38,7 @@ from .const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) +from .coordinator import BMWConfigEntry DATA_SCHEMA = vol.Schema( { @@ -53,6 +53,12 @@ DATA_SCHEMA = vol.Schema( }, extra=vol.REMOVE_EXTRA, ) +RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + }, + extra=vol.REMOVE_EXTRA, +) CAPTCHA_SCHEMA = vol.Schema( { vol.Required(CONF_CAPTCHA_TOKEN): str, @@ -111,9 +117,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" await self.async_set_unique_id(unique_id) - if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: - self._abort_if_unique_id_mismatch(reason="account_mismatch") - else: + # Unique ID cannot change for reauth/reconfigure + if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: self._abort_if_unique_id_configured() # Store user input for later use @@ -166,19 +171,39 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + async def async_step_change_password( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show the change password step.""" + existing_data = ( + dict(self._existing_entry_data) if self._existing_entry_data else {} + ) + + if user_input is not None: + return await self.async_step_user(existing_data | user_input) + + return self.async_show_form( + step_id="change_password", + data_schema=RECONFIGURE_SCHEMA, + description_placeholders={ + CONF_USERNAME: existing_data[CONF_USERNAME], + CONF_REGION: existing_data[CONF_REGION], + }, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" self._existing_entry_data = entry_data - return await self.async_step_user() + return await self.async_step_change_password() async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a reconfiguration flow initialized by the user.""" self._existing_entry_data = self._get_reconfigure_entry().data - return await self.async_step_user() + return await self.async_step_change_password() async def async_step_captcha( self, user_input: dict[str, Any] | None = None @@ -199,7 +224,7 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, ) -> BMWOptionsFlow: """Return a MyBMW option flow.""" return BMWOptionsFlow() diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 4f560d16f9c..b54d9245bbd 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -22,39 +22,51 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.ssl import get_default_context -from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS +from .const import ( + CONF_GCID, + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, + DOMAIN as BMW_DOMAIN, + SCAN_INTERVALS, +) _LOGGER = logging.getLogger(__name__) +type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator] + + class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching BMW data.""" account: MyBMWAccount + config_entry: BMWConfigEntry - def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None: """Initialize account-wide BMW data updater.""" self.account = MyBMWAccount( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - get_region_from_name(entry.data[CONF_REGION]), + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + get_region_from_name(config_entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), verify=get_default_context(), ) - self.read_only = entry.options[CONF_READ_ONLY] - self._entry = entry + self.read_only: bool = config_entry.options[CONF_READ_ONLY] - if CONF_REFRESH_TOKEN in entry.data: + if CONF_REFRESH_TOKEN in config_entry.data: self.account.set_refresh_token( - refresh_token=entry.data[CONF_REFRESH_TOKEN], - gcid=entry.data.get(CONF_GCID), + refresh_token=config_entry.data[CONF_REFRESH_TOKEN], + gcid=config_entry.data.get(CONF_GCID), ) super().__init__( hass, _LOGGER, - name=f"{DOMAIN}-{entry.data['username']}", - update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]), + config_entry=config_entry, + name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}", + update_interval=timedelta( + seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]] + ), ) # Default to false on init so _async_update_data logic works @@ -69,18 +81,29 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): except MyBMWCaptchaMissingError as err: # If a captcha is required (user/password login flow), always trigger the reauth flow raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, + translation_domain=BMW_DOMAIN, translation_key="missing_captcha", ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=BMW_DOMAIN, + translation_key="update_failed", + translation_placeholders={"exception": str(err)}, + ) from err # Clear refresh token and trigger reauth if previous update failed as well self._update_config_entry_refresh_token(None) - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=BMW_DOMAIN, + translation_key="invalid_auth", + ) from err except (MyBMWAPIError, RequestError) as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=BMW_DOMAIN, + translation_key="update_failed", + translation_placeholders={"exception": str(err)}, + ) from err if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) @@ -88,9 +111,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { - **self._entry.data, + **self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token, } if not refresh_token: data.pop(CONF_REFRESH_TOKEN) - self.hass.config_entries.async_update_entry(self._entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index b65c2c1b088..74df8693f7a 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -27,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW tracker from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWDeviceTracker] = [] for vehicle in coordinator.account.vehicles: @@ -49,7 +49,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): _attr_force_update = False _attr_translation_key = "car" - _attr_icon = "mdi:car" + _attr_name = None def __init__( self, @@ -58,9 +58,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): ) -> None: """Initialize the Tracker.""" super().__init__(coordinator, vehicle) - self._attr_unique_id = vehicle.vin - self._attr_name = None @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/bmw_connected_drive/diagnostics.py b/homeassistant/components/bmw_connected_drive/diagnostics.py index 3950ea3dec2..3f357c3ae79 100644 --- a/homeassistant/components/bmw_connected_drive/diagnostics.py +++ b/homeassistant/components/bmw_connected_drive/diagnostics.py @@ -51,7 +51,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BMWConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data coordinator.account.config.log_responses = True await coordinator.account.get_vehicles(force_init=True) @@ -77,7 +77,7 @@ async def async_get_device_diagnostics( hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data coordinator.account.config.log_responses = True await coordinator.account.get_vehicles(force_init=True) diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index b715a1e38cc..4bec12e796b 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 AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data if not coordinator.read_only: async_add_entities( @@ -70,7 +70,11 @@ class BMWLock(BMWBaseEntity, LockEntity): # Set the state to unknown if the command fails self._attr_is_locked = None self.async_write_ha_state() - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex finally: # Always update the listeners to get the latest state self.coordinator.async_update_listeners() @@ -90,7 +94,11 @@ class BMWLock(BMWBaseEntity, LockEntity): # Set the state to unknown if the command fails self._attr_is_locked = None self.async_write_ha_state() - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex finally: # Always update the listeners to get the latest state self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 662a73a20cd..dfa0939e81f 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, BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry PARALLEL_UPDATES = 1 @@ -53,7 +53,7 @@ def get_service( targets = {} if ( config_entry - and (coordinator := config_entry.runtime_data.coordinator) + and (coordinator := config_entry.runtime_data) and not coordinator.read_only ): targets.update({v.name: v for v in coordinator.account.vehicles}) @@ -92,7 +92,7 @@ class BMWNotificationService(BaseNotificationService): except (vol.Invalid, TypeError, ValueError) as ex: raise ServiceValidationError( - translation_domain=DOMAIN, + translation_domain=BMW_DOMAIN, translation_key="invalid_poi", translation_placeholders={ "poi_exception": str(ex), @@ -106,4 +106,8 @@ class BMWNotificationService(BaseNotificationService): try: await vehicle.remote_services.trigger_send_poi(poi) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index cce71b3b2fd..c6a328ecc20 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 AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -61,7 +61,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW number from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWNumber] = [] @@ -109,6 +109,10 @@ class BMWNumber(BMWBaseEntity, NumberEntity): try: await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 7bc91b098ae..385b45fd9fa 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 AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -68,7 +68,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWSelect] = [] @@ -123,6 +123,10 @@ class BMWSelect(BMWBaseEntity, SelectEntity): try: await self.entity_description.remote_service(self.vehicle, option) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 555655511e8..b7be367d57d 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -193,7 +193,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW sensors from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities = [ BMWSensor(coordinator, vehicle, description) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 8078971acd1..edb0d5cfb12 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -2,10 +2,16 @@ "config": { "step": { "user": { + "description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "region": "ConnectedDrive Region" + }, + "data_description": { + "username": "The email address of your MyBMW/MINI Connected account.", + "password": "The password of your MyBMW/MINI Connected account.", + "region": "The region of your MyBMW/MINI Connected account." } }, "captcha": { @@ -17,6 +23,15 @@ "data_description": { "captcha_token": "One-time token retrieved from the captcha challenge." } + }, + "change_password": { + "description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]" + } } }, "error": { @@ -27,15 +42,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "account_mismatch": "Username and region are not allowed to change" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { "step": { "account_options": { "data": { - "read_only": "Read-only (only sensors and notify, no execution of services, no lock)" + "read_only": "Read-only mode" + }, + "data_description": { + "read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state." } } } @@ -77,6 +94,9 @@ "activate_air_conditioning": { "name": "Activate air conditioning" }, + "deactivate_air_conditioning": { + "name": "Deactivate air conditioning" + }, "find_vehicle": { "name": "Find vehicle" } @@ -214,6 +234,15 @@ }, "missing_captcha": { "message": "Login requires captcha validation" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "remote_service_error": { + "message": "Error executing remote service on vehicle. {exception}" + }, + "update_failed": { + "message": "Error updating vehicle data. {exception}" } } } diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index f0214bc1262..600ad41165a 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 AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -69,7 +69,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW switch from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWSwitch] = [] @@ -111,8 +111,11 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): try: await self.entity_description.remote_service_on(self.vehicle) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex - + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() async def async_turn_off(self, **kwargs: Any) -> None: @@ -120,6 +123,9 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): try: await self.entity_description.remote_service_off(self.vehicle) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex - + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 606c280cf8d..b8ee9d1e6ae 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -85,6 +85,7 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if not (errors := await self.validate_input(user_input)): + self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( self.reauth_entry, data=user_input ) diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml new file mode 100644 index 00000000000..1fdb3f13f1b --- /dev/null +++ b/homeassistant/components/bring/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Only entity services + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: todo + dependency-transparency: done + docs-actions: done + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: The integration registers no events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: handled by coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration is a service and has no devices. + discovery: + status: exempt + comment: Integration is a service and has no devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + no repairs + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index eddee46f3bc..bd33ce9bf88 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -24,6 +24,8 @@ from .coordinator import BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity from .util import list_language, sum_attributes +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class BringSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index c8c12090118..7331f68a161 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -26,7 +26,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." } }, "entity": { diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 319aedc6b80..c53b5788b68 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -34,6 +34,8 @@ from .const import ( from .coordinator import BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index dbfd982795c..25a6bbd60a5 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -52,7 +52,6 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity): ) _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: BroadlinkDevice) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py index dd31097a1ee..2d54ced8217 100644 --- a/homeassistant/components/bryant_evolution/climate.py +++ b/homeassistant/components/bryant_evolution/climate.py @@ -77,7 +77,6 @@ class BryantEvolutionClimate(ClimateEntity): HVACMode.OFF, ] _attr_fan_modes = ["auto", "low", "med", "high"] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 6d992da395a..2833d6549b4 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -65,7 +65,6 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): _attr_preset_modes = PRESET_MODES _attr_hvac_modes = HVAC_MODES - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py index ca587ee9a48..6f5a92feac0 100644 --- a/homeassistant/components/cambridge_audio/config_flow.py +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -7,12 +7,18 @@ from aiostreammagic import StreamMagicClient import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): """Cambridge Audio configuration flow.""" @@ -64,6 +70,17 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + if not user_input: + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, + ) + return await self.async_step_user(user_input) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -82,6 +99,12 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( client.info.unit_id, raise_on_progress=False ) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) self._abort_if_unique_id_configured() return self.async_create_entry( title=client.info.name, @@ -91,6 +114,6 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): await client.disconnect() return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + data_schema=DATA_SCHEMA, errors=errors, ) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 7b7e341e3c6..14a389587d2 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], + "quality_scale": "platinum", "requirements": ["aiostreammagic==2.10.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 805cf8ec7f6..9896effb07d 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -20,11 +20,11 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import CambridgeAudioConfigEntry from .const import ( CAMBRIDGE_MEDIA_TYPE_AIRABLE, CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO, @@ -62,7 +62,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CambridgeAudioConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Cambridge Audio device based on a config entry.""" diff --git a/homeassistant/components/cambridge_audio/quality_scale.yaml b/homeassistant/components/cambridge_audio/quality_scale.yaml new file mode 100644 index 00000000000..e5cafdd6368 --- /dev/null +++ b/homeassistant/components/cambridge_audio/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions beyond play media which is setup by the media player entity. + appropriate-polling: + status: exempt + comment: | + This integration uses a push API. No polling required. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: + status: exempt + comment: | + This integration is not a hub and as such only represents a single device. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + discovery-update-info: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index b1bc0f9e4df..6bfe83c2539 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -7,11 +7,11 @@ from aiostreammagic import StreamMagicClient from aiostreammagic.models import DisplayBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import CambridgeAudioConfigEntry from .entity import CambridgeAudioEntity, command PARALLEL_UPDATES = 0 @@ -81,7 +81,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CambridgeAudioConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Cambridge Audio select entities based on a config entry.""" diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index c368ba060a7..9f5e031815b 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -13,12 +13,23 @@ }, "discovery_confirm": { "description": "Do you want to setup {name}?" + }, + "reconfigure": { + "description": "Reconfigure your Cambridge Audio Streamer.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::cambridge_audio::config::step::user::data_description::host%]" + } } }, "error": { "cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect." }, "abort": { + "wrong_device": "This Cambridge Audio device does not match the existing device id. Please make sure you entered the correct IP address.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py index 72aa0d3cbea..065a1da4f94 100644 --- a/homeassistant/components/cambridge_audio/switch.py +++ b/homeassistant/components/cambridge_audio/switch.py @@ -7,11 +7,11 @@ from typing import Any from aiostreammagic import StreamMagicClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import CambridgeAudioConfigEntry from .entity import CambridgeAudioEntity, command PARALLEL_UPDATES = 0 @@ -45,7 +45,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CambridgeAudioConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Cambridge Audio switch entities based on a config entry.""" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4d718433fca..725fc84adc3 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -516,19 +516,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> CameraEntityFeature: - """Return the supported features as CameraEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = CameraEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -582,7 +569,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self._deprecate_attr_frontend_stream_type_logged = True return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features_compat: + if CameraEntityFeature.STREAM not in self.supported_features: return None if ( self._webrtc_provider @@ -811,9 +798,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - self.__supports_stream = ( - self.supported_features_compat & CameraEntityFeature.STREAM - ) + self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -853,7 +838,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] ) -> _T | None: """Get first provider that supports this camera.""" - if CameraEntityFeature.STREAM not in self.supported_features_compat: + if CameraEntityFeature.STREAM not in self.supported_features: return None return await fn(self.hass, self) @@ -911,7 +896,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() - if CameraEntityFeature.STREAM in self.supported_features_compat: + if CameraEntityFeature.STREAM in self.supported_features: if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) @@ -931,8 +916,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ super().async_write_ha_state() if self.__supports_stream != ( - supports_stream := self.supported_features_compat - & CameraEntityFeature.STREAM + supports_stream := self.supported_features & CameraEntityFeature.STREAM ): self.__supports_stream = supports_stream self._invalidate_camera_capabilities_cache() diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index a6e5d2cab61..3db8c3e1016 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -70,7 +70,6 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index 22d443c700d..c351435a73e 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -74,7 +74,7 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN): title=title, data={CONF_HOST: host, CONF_PORT: port}, ) - if self.context["source"] == SOURCE_IMPORT: + if self.source == SOURCE_IMPORT: _LOGGER.error("Config import failed for %s", user_input[CONF_HOST]) return self.async_abort(reason="import_failed") else: @@ -94,10 +94,3 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=self._errors, ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry. - - Only host was required in the yaml file all other fields are optional - """ - return await self.async_step_user(import_data) diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index a6f163b51be..4fd0846f0f3 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -2,63 +2,18 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START -from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import CertExpiryConfigEntry -from .const import DEFAULT_PORT, DOMAIN +from .const import DOMAIN from .coordinator import CertExpiryDataUpdateCoordinator from .entity import CertExpiryEntity -SCAN_INTERVAL = timedelta(hours=12) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up certificate expiry sensor.""" - - @callback - def schedule_import(_: Event) -> None: - """Schedule delayed import after HA is fully started.""" - async_call_later(hass, 10, do_import) - - @callback - def do_import(_: datetime) -> None: - """Process YAML import.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) - ) - ) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_import) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 045003dcd0f..ca85979f19a 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from datetime import timedelta import functools as ft import logging @@ -28,7 +27,6 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue @@ -303,115 +301,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): __climate_reported_legacy_aux = False - __mod_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) - # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False - # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. - _enable_turn_on_off_backwards_compatibility: bool = True - - def __getattribute__(self, name: str, /) -> Any: - """Get attribute. - - Modify return of `supported_features` to - include `_mod_supported_features` if attribute is set. - """ - if name != "supported_features": - return super().__getattribute__(name) - - # Convert the supported features to ClimateEntityFeature. - # Remove this compatibility shim in 2025.1 or later. - _supported_features: ClimateEntityFeature = super().__getattribute__( - "supported_features" - ) - _mod_supported_features: ClimateEntityFeature = super().__getattribute__( - "_ClimateEntity__mod_supported_features" - ) - if type(_supported_features) is int: # noqa: E721 - _features = ClimateEntityFeature(_supported_features) - self._report_deprecated_supported_features_values(_features) - else: - _features = _supported_features - - if not _mod_supported_features: - return _features - - # Add automatically calculated ClimateEntityFeature.TURN_OFF/TURN_ON to - # supported features and return it - return _features | _mod_supported_features - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - - def _report_turn_on_off(feature: str, method: str) -> None: - """Log warning not implemented turn on/off feature.""" - report_issue = self._suggest_report_issue() - if feature.startswith("TURN"): - message = ( - "Entity %s (%s) does not set ClimateEntityFeature.%s" - " but implements the %s method. Please %s" - ) - else: - message = ( - "Entity %s (%s) implements HVACMode(s): %s and therefore implicitly" - " supports the %s methods without setting the proper" - " ClimateEntityFeature. Please %s" - ) - _LOGGER.warning( - message, - self.entity_id, - type(self), - feature, - method, - report_issue, - ) - - # Adds ClimateEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented - # This should be removed in 2025.1. - if self._enable_turn_on_off_backwards_compatibility is False: - # Return if integration has migrated already - return - - supported_features = self.supported_features - if supported_features & CHECK_TURN_ON_OFF_FEATURE_FLAG: - # The entity supports both turn_on and turn_off, the backwards compatibility - # checks are not needed - return - - if not supported_features & ClimateEntityFeature.TURN_OFF and ( - type(self).async_turn_off is not ClimateEntity.async_turn_off - or type(self).turn_off is not ClimateEntity.turn_off - ): - # turn_off implicitly supported by implementing turn_off method - _report_turn_on_off("TURN_OFF", "turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_OFF - ) - - if not supported_features & ClimateEntityFeature.TURN_ON and ( - type(self).async_turn_on is not ClimateEntity.async_turn_on - or type(self).turn_on is not ClimateEntity.turn_on - ): - # turn_on implicitly supported by implementing turn_on method - _report_turn_on_off("TURN_ON", "turn_on") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_ON - ) - - if (modes := self.hvac_modes) and len(modes) >= 2 and HVACMode.OFF in modes: - # turn_on/off implicitly supported by including more modes than 1 and one of these - # are HVACMode.OFF - _modes = [_mode for _mode in modes if _mode is not None] - _report_turn_on_off(", ".join(_modes or []), "turn_on/turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - ) - def _report_legacy_aux(self) -> None: """Log warning and create an issue if the entity implements legacy auxiliary heater.""" diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py new file mode 100644 index 00000000000..d394daa7dc5 --- /dev/null +++ b/homeassistant/components/cloud/backup.py @@ -0,0 +1,202 @@ +"""Backup platform for the cloud integration.""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncIterator, Callable, Coroutine +import hashlib +from typing import Any, Self + +from aiohttp import ClientError, ClientTimeout, StreamReader +from hass_nabucasa import Cloud, CloudError +from hass_nabucasa.cloud_api import ( + async_files_delete_file, + async_files_download_details, + async_files_list, + async_files_upload_details, +) + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback + +from .client import CloudClient +from .const import DATA_CLOUD, DOMAIN + +_STORAGE_BACKUP = "backup" + + +async def _b64md5(stream: AsyncIterator[bytes]) -> str: + """Calculate the MD5 hash of a file.""" + file_hash = hashlib.md5() + async for chunk in stream: + file_hash.update(chunk) + return base64.b64encode(file_hash.digest()).decode() + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the cloud backup agent.""" + cloud = hass.data[DATA_CLOUD] + if not cloud.is_logged_in: + return [] + + return [CloudBackupAgent(hass=hass, cloud=cloud)] + + +class ChunkAsyncStreamIterator: + """Async iterator for chunked streams. + + Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields + bytes instead of tuple[bytes, bool]. + """ + + __slots__ = ("_stream",) + + def __init__(self, stream: StreamReader) -> None: + """Initialize.""" + self._stream = stream + + def __aiter__(self) -> Self: + """Iterate.""" + return self + + async def __anext__(self) -> bytes: + """Yield next chunk.""" + rv = await self._stream.readchunk() + if rv == (b"", False): + raise StopAsyncIteration + return rv[0] + + +class CloudBackupAgent(BackupAgent): + """Cloud backup agent.""" + + domain = DOMAIN + name = DOMAIN + + def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: + """Initialize the cloud backup sync agent.""" + super().__init__() + self._cloud = cloud + self._hass = hass + + @callback + def _get_backup_filename(self) -> str: + """Return the backup filename.""" + return f"{self._cloud.client.prefs.instance_id}.tar" + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + if not await self.async_get_backup(backup_id): + raise BackupAgentError("Backup not found") + + try: + details = await async_files_download_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to get download details") from err + + try: + resp = await self._cloud.websession.get(details["url"]) + resp.raise_for_status() + except ClientError as err: + raise BackupAgentError("Failed to download backup") from err + + return ChunkAsyncStreamIterator(resp.content) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + if not backup.protected: + raise BackupAgentError("Cloud backups must be protected") + + base64md5hash = await _b64md5(await open_stream()) + + try: + details = await async_files_upload_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + metadata=backup.as_dict(), + size=backup.size, + base64md5hash=base64md5hash, + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to get upload details") from err + + try: + upload_status = await self._cloud.websession.put( + details["url"], + data=await open_stream(), + headers=details["headers"] | {"content-length": str(backup.size)}, + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + upload_status.raise_for_status() + except (TimeoutError, ClientError) as err: + raise BackupAgentError("Failed to upload backup") from err + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + if not await self.async_get_backup(backup_id): + raise BackupAgentError("Backup not found") + + try: + await async_files_delete_file( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to delete backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + try: + backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to list backups") from err + + return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + + for backup in backups: + if backup.backup_id == backup_id: + return backup + + return None diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 60b105b401e..7ee8cf46b86 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -1,13 +1,18 @@ { "domain": "cloud", "name": "Home Assistant Cloud", - "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], + "after_dependencies": [ + "alexa", + "assist_pipeline", + "backup", + "google_assistant" + ], "codeowners": ["@home-assistant/cloud"], "dependencies": ["auth", "http", "repairs", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.85.0"], + "requirements": ["hass-nabucasa==0.87.0"], "single_config_entry": true } diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 0b88367c0fa..6dc7c7e26d9 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -100,7 +100,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 4e30b3ee3dc..2295fdb4e8e 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -68,7 +68,7 @@ class ComfoConnectFan(FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_preset_modes = PRESET_MODES current_speed: float | None = None diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json index 3e76cf4a6a6..2a54f500504 100644 --- a/homeassistant/components/command_line/manifest.json +++ b/homeassistant/components/command_line/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/command_line", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["jsonpath==0.82.2"] } diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 5b3cc5ac2ac..ac82938b97b 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.1.3"] + "requirements": ["numpy==2.2.0"] } diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index da50f7e93a1..5794819995d 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -46,6 +46,13 @@ def async_setup(hass: HomeAssistant) -> bool: hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + hass.http.register_view( + SubentryManagerFlowIndexView(hass.config_entries.subentries) + ) + hass.http.register_view( + SubentryManagerFlowResourceView(hass.config_entries.subentries) + ) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_get_single) @@ -54,6 +61,9 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, config_entries_progress) websocket_api.async_register_command(hass, ignore_config_flow) + websocket_api.async_register_command(hass, config_subentry_delete) + websocket_api.async_register_command(hass, config_subentry_list) + return True @@ -285,6 +295,63 @@ class OptionManagerFlowResourceView( return await super().post(request, flow_id) +class SubentryManagerFlowIndexView( + FlowManagerIndexView[config_entries.ConfigSubentryFlowManager] +): + """View to create subentry flows.""" + + 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) + ) + @RequestDataValidator( + vol.Schema( + { + vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)), + vol.Optional("show_advanced_options", default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, + ) + ) + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + """Handle a POST request. + + handler in request is [entry_id, subentry_type]. + """ + return await super()._post_impl(request, data) + + def get_context(self, data: dict[str, Any]) -> dict[str, Any]: + """Return context.""" + context = super().get_context(data) + context["source"] = config_entries.SOURCE_USER + return context + + +class SubentryManagerFlowResourceView( + FlowManagerResourceView[config_entries.ConfigSubentryFlowManager] +): + """View to interact with the subentry flow manager.""" + + 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) + ) + 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) + ) + async def post(self, request: web.Request, flow_id: str) -> web.Response: + """Handle a POST request.""" + return await super().post(request, flow_id) + + @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) def config_entries_progress( @@ -588,3 +655,62 @@ async def _async_matching_config_entries_json_fragments( ) or (filter_is_not_helper and entry.domain not in integrations) ] + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/subentries/list", + "entry_id": str, + } +) +@websocket_api.async_response +async def config_subentry_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List subentries of a config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + result = [ + { + "subentry_id": subentry.subentry_id, + "title": subentry.title, + "unique_id": subentry.unique_id, + } + for subentry_id, subentry in entry.subentries.items() + ] + connection.send_result(msg["id"], result) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/subentries/delete", + "entry_id": str, + "subentry_id": str, + } +) +@websocket_api.async_response +async def config_subentry_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Delete a subentry of a config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + try: + hass.config_entries.async_remove_subentry(entry, msg["subentry_id"]) + except config_entries.UnknownSubEntry: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found" + ) + return + + connection.send_result(msg["id"]) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 59c09232b93..66ffb25fa1a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -246,7 +246,7 @@ class DefaultAgent(ConversationEntity): self._unexposed_names_trie: Trie | None = None # Sentences that will trigger a callback (skipping intent recognition) - self._trigger_sentences: list[TriggerData] = [] + self.trigger_sentences: list[TriggerData] = [] self._trigger_intents: Intents | None = None self._unsub_clear_slot_list: list[Callable[[], None]] | None = None self._load_intents_lock = asyncio.Lock() @@ -711,7 +711,7 @@ class DefaultAgent(ConversationEntity): for name_tuple in self._get_entity_name_tuples(exposed=False): self._unexposed_names_trie.insert( name_tuple[0].lower(), - TextSlotValue.from_tuple(name_tuple), + TextSlotValue.from_tuple(name_tuple, allow_template=False), ) # Build filtered slot list @@ -1188,7 +1188,7 @@ class DefaultAgent(ConversationEntity): ) -> core.CALLBACK_TYPE: """Register a list of sentences that will trigger a callback when recognized.""" trigger_data = TriggerData(sentences=sentences, callback=callback) - self._trigger_sentences.append(trigger_data) + self.trigger_sentences.append(trigger_data) # Force rebuild on next use self._trigger_intents = None @@ -1205,7 +1205,7 @@ class DefaultAgent(ConversationEntity): # This works because the intents are rebuilt on every # register/unregister. str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]} - for trigger_id, trigger_data in enumerate(self._trigger_sentences) + for trigger_id, trigger_data in enumerate(self.trigger_sentences) }, } @@ -1228,7 +1228,7 @@ class DefaultAgent(ConversationEntity): @core.callback def _unregister_trigger(self, trigger_data: TriggerData) -> None: """Unregister a set of trigger sentences.""" - self._trigger_sentences.remove(trigger_data) + self.trigger_sentences.remove(trigger_data) # Force rebuild on next use self._trigger_intents = None @@ -1241,7 +1241,7 @@ class DefaultAgent(ConversationEntity): Calls the registered callbacks if there's a match and returns a sentence trigger result. """ - if not self._trigger_sentences: + if not self.trigger_sentences: # No triggers registered return None @@ -1286,7 +1286,7 @@ class DefaultAgent(ConversationEntity): # Gather callback responses in parallel trigger_callbacks = [ - self._trigger_sentences[trigger_id].callback(user_input, trigger_result) + self.trigger_sentences[trigger_id].callback(user_input, trigger_result) for trigger_id, trigger_result in result.matched_triggers.items() ] diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index ebc5d70f1ef..8134ecb0eee 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -24,7 +24,7 @@ from .agent_manager import ( get_agent_manager, ) from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY -from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, DefaultAgent +from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE from .entity import ConversationEntity from .models import ConversationInput @@ -36,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_process) websocket_api.async_register_command(hass, websocket_prepare) websocket_api.async_register_command(hass, websocket_list_agents) + websocket_api.async_register_command(hass, websocket_list_sentences) websocket_api.async_register_command(hass, websocket_hass_agent_debug) @@ -150,6 +151,26 @@ async def websocket_list_agents( connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/sentences/list", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_list_sentences( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List custom registered sentences.""" + agent = hass.data[DATA_DEFAULT_ENTITY] + + sentences = [] + for trigger_data in agent.trigger_sentences: + sentences.extend(trigger_data.sentences) + + connection.send_result(msg["id"], {"trigger_sentences": sentences}) + + @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/homeassistant/debug", @@ -163,8 +184,7 @@ async def websocket_hass_agent_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Return intents that would be matched by the default agent for a list of sentences.""" - agent = hass.data.get(DATA_DEFAULT_ENTITY) - assert isinstance(agent, DefaultAgent) + agent = hass.data[DATA_DEFAULT_ENTITY] # Return results for each sentence in the same order as the input. result_dicts: list[dict[str, Any] | None] = [] diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d2f2f58a3a..41c9a2d2691 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.2"] + "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.9"] } diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py new file mode 100644 index 00000000000..bb78f2a569d --- /dev/null +++ b/homeassistant/components/cookidoo/__init__.py @@ -0,0 +1,49 @@ +"""The Cookidoo integration.""" + +from __future__ import annotations + +from cookidoo_api import Cookidoo, CookidooConfig, CookidooLocalizationConfig + +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: + """Set up Cookidoo from a config entry.""" + + cookidoo = Cookidoo( + async_get_clientsession(hass), + CookidooConfig( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + localization=CookidooLocalizationConfig( + country_code=entry.data[CONF_COUNTRY].lower(), + language=entry.data[CONF_LANGUAGE], + ), + ), + ) + + coordinator = CookidooDataUpdateCoordinator(hass, cookidoo, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py new file mode 100644 index 00000000000..58e99a70907 --- /dev/null +++ b/homeassistant/components/cookidoo/config_flow.py @@ -0,0 +1,204 @@ +"""Config flow for Cookidoo integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from cookidoo_api import ( + Cookidoo, + CookidooAuthException, + CookidooConfig, + CookidooLocalizationConfig, + CookidooRequestException, + get_country_options, + get_localization_options, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + LanguageSelector, + LanguageSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +AUTH_DATA_SCHEMA = { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), +} + + +class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cookidoo.""" + + COUNTRY_DATA_SCHEMA: dict + LANGUAGE_DATA_SCHEMA: dict + + user_input: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + + if user_input is not None and not ( + errors := await self.validate_input(user_input) + ): + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + self.user_input = user_input + return await self.async_step_language() + await self.generate_country_schema() + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + {**AUTH_DATA_SCHEMA, **self.COUNTRY_DATA_SCHEMA} + ), + suggested_values=user_input, + ), + description_placeholders={"cookidoo": "Cookidoo"}, + errors=errors, + ) + + async def async_step_language( + self, + language_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Async language step to set up the connection.""" + errors: dict[str, str] = {} + if language_input is not None and not ( + errors := await self.validate_input(self.user_input, language_input) + ): + return self.async_create_entry( + title="Cookidoo", data={**self.user_input, **language_input} + ) + + await self.generate_language_schema() + return self.async_show_form( + step_id="language", + data_schema=vol.Schema(self.LANGUAGE_DATA_SCHEMA), + description_placeholders={"cookidoo": "Cookidoo"}, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + if not ( + errors := await self.validate_input({**reauth_entry.data, **user_input}) + ): + if user_input[CONF_EMAIL] != reauth_entry.data[CONF_EMAIL]: + self._async_abort_entries_match( + {CONF_EMAIL: user_input[CONF_EMAIL]} + ) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema(AUTH_DATA_SCHEMA), + suggested_values={CONF_EMAIL: reauth_entry.data[CONF_EMAIL]}, + ), + description_placeholders={"cookidoo": "Cookidoo"}, + errors=errors, + ) + + async def generate_country_schema(self) -> None: + """Generate country schema.""" + self.COUNTRY_DATA_SCHEMA = { + vol.Required(CONF_COUNTRY): CountrySelector( + CountrySelectorConfig( + countries=[ + country.upper() for country in await get_country_options() + ], + ) + ) + } + + async def generate_language_schema(self) -> None: + """Generate language schema.""" + self.LANGUAGE_DATA_SCHEMA = { + vol.Required(CONF_LANGUAGE): LanguageSelector( + LanguageSelectorConfig( + languages=[ + option.language + for option in await get_localization_options( + country=self.user_input[CONF_COUNTRY].lower() + ) + ], + native_name=True, + ), + ), + } + + async def validate_input( + self, + user_input: Mapping[str, Any], + language_input: Mapping[str, Any] | None = None, + ) -> dict[str, str]: + """Input Helper.""" + + errors: dict[str, str] = {} + + session = async_get_clientsession(self.hass) + cookidoo = Cookidoo( + session, + CookidooConfig( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + localization=CookidooLocalizationConfig( + country_code=user_input[CONF_COUNTRY].lower(), + language=language_input[CONF_LANGUAGE] + if language_input + else "de-ch", + ), + ), + ) + try: + await cookidoo.login() + if language_input: + await cookidoo.get_additional_items() + except CookidooRequestException: + errors["base"] = "cannot_connect" + except CookidooAuthException: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py new file mode 100644 index 00000000000..37c584404a0 --- /dev/null +++ b/homeassistant/components/cookidoo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Cookidoo integration.""" + +DOMAIN = "cookidoo" diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py new file mode 100644 index 00000000000..ad86d1fb9f1 --- /dev/null +++ b/homeassistant/components/cookidoo/coordinator.py @@ -0,0 +1,101 @@ +"""DataUpdateCoordinator for the Cookidoo integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from cookidoo_api import ( + Cookidoo, + CookidooAdditionalItem, + CookidooAuthException, + CookidooException, + CookidooIngredientItem, + CookidooRequestException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type CookidooConfigEntry = ConfigEntry[CookidooDataUpdateCoordinator] + + +@dataclass +class CookidooData: + """Cookidoo data type.""" + + ingredient_items: list[CookidooIngredientItem] + additional_items: list[CookidooAdditionalItem] + + +class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): + """A Cookidoo Data Update Coordinator.""" + + config_entry: CookidooConfigEntry + + def __init__( + self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry + ) -> None: + """Initialize the Cookidoo data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + config_entry=entry, + ) + self.cookidoo = cookidoo + + async def _async_setup(self) -> None: + try: + await self.cookidoo.login() + except CookidooRequestException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except CookidooAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from e + + async def _async_update_data(self) -> CookidooData: + try: + ingredient_items = await self.cookidoo.get_ingredient_items() + additional_items = await self.cookidoo.get_additional_items() + except CookidooAuthException: + try: + await self.cookidoo.refresh_token() + except CookidooAuthException as exc: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from exc + _LOGGER.debug( + "Authentication failed but re-authentication was successful, trying again later" + ) + return self.data + except CookidooException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_exception", + ) from e + + return CookidooData( + ingredient_items=ingredient_items, additional_items=additional_items + ) diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py new file mode 100644 index 00000000000..5c8f3ec8441 --- /dev/null +++ b/homeassistant/components/cookidoo/entity.py @@ -0,0 +1,30 @@ +"""Base entity for the Cookidoo integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CookidooDataUpdateCoordinator + + +class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]): + """Cookidoo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: CookidooDataUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name="Cookidoo", + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Vorwerk International & Co. KmG", + model="Cookidoo - Thermomix® recipe portal", + ) diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json new file mode 100644 index 00000000000..36c0724331a --- /dev/null +++ b/homeassistant/components/cookidoo/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "todo": { + "ingredient_list": { + "default": "mdi:cart-plus" + }, + "additional_item_list": { + "default": "mdi:cart-plus" + } + } + } +} diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json new file mode 100644 index 00000000000..59d58200fdf --- /dev/null +++ b/homeassistant/components/cookidoo/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "cookidoo", + "name": "Cookidoo", + "codeowners": ["@miaucl"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cookidoo", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["cookidoo-api==0.10.0"] +} diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml new file mode 100644 index 00000000000..25069c87c46 --- /dev/null +++ b/homeassistant/components/cookidoo/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions implemented + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: exempt + comment: No special external action required + entity-event-setup: + status: exempt + comment: No callbacks are implemented + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: + status: done + comment: Offloaded to coordinator + entity-unavailable: + status: done + comment: Offloaded to coordinator + action-exceptions: + status: done + comment: Only providing todo actions + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow + + # Gold + entity-translations: done + entity-device-class: + status: exempt + comment: currently no platform with device classes + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: No disabled entities implemented + discovery: + status: exempt + comment: Nothing to discover + stale-devices: + status: exempt + comment: No stale entities possible + diagnostics: todo + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: No dynamic entities available + discovery-update-info: + status: exempt + comment: No discoverable entities implemented + repair-issues: + status: exempt + comment: No issues/repairs + docs-use-cases: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json new file mode 100644 index 00000000000..19f709ddaf8 --- /dev/null +++ b/homeassistant/components/cookidoo/strings.json @@ -0,0 +1,80 @@ +{ + "config": { + "step": { + "user": { + "title": "Login to {cookidoo}", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]", + "country": "Country" + }, + "data_description": { + "email": "Email used to access your {cookidoo} account.", + "password": "Password used to access your {cookidoo} account.", + "country": "Pick your language for the {cookidoo} content." + } + }, + "language": { + "title": "Set language for {cookidoo}", + "data": { + "language": "[%key:common::config_flow::data::language%]" + }, + "data_description": { + "language": "Pick your language for the {cookidoo} content." + } + }, + "reauth_confirm": { + "title": "Login again to {cookidoo}", + "description": "Please log in to {cookidoo} again to continue using this integration.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::cookidoo::config::step::user::data_description::email%]", + "password": "[%key:component::cookidoo::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "entity": { + "todo": { + "ingredient_list": { + "name": "Shopping list" + }, + "additional_item_list": { + "name": "Additional purchases" + } + } + }, + "exceptions": { + "todo_save_item_failed": { + "message": "Failed to save {name} to Cookidoo shopping list" + }, + "todo_update_item_failed": { + "message": "Failed to update {name} in Cookidoo shopping list" + }, + "todo_delete_item_failed": { + "message": "Failed to delete {count} item(s) from Cookidoo shopping list" + }, + "setup_request_exception": { + "message": "Failed to connect to server, try again later" + }, + "setup_authentication_exception": { + "message": "Authentication failed for {email}, check your email and password" + }, + "update_exception": { + "message": "Unable to connect and retrieve data from cookidoo" + } + } +} diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py new file mode 100644 index 00000000000..4a70dadc65a --- /dev/null +++ b/homeassistant/components/cookidoo/todo.py @@ -0,0 +1,185 @@ +"""Todo platform for the Cookidoo integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cookidoo_api import ( + CookidooAdditionalItem, + CookidooException, + CookidooIngredientItem, +) + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .entity import CookidooBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the todo list from a config entry created in the integrations UI.""" + coordinator = config_entry.runtime_data + + async_add_entities( + [ + CookidooIngredientsTodoListEntity(coordinator), + CookidooAdditionalItemTodoListEntity(coordinator), + ] + ) + + +class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): + """A To-do List representation of the ingredients in the Cookidoo Shopping List.""" + + _attr_translation_key = "ingredient_list" + _attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM + + def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients" + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo ingredients.""" + return [ + TodoItem( + uid=item.id, + summary=item.name, + description=item.description or "", + status=( + TodoItemStatus.COMPLETED + if item.is_owned + else TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self.coordinator.data.ingredient_items + ] + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an ingredient to the To-do list. + + Cookidoo ingredients can be changed in state, but not in summary or description. This is currently not possible to distinguish in home assistant and just fails silently. + """ + try: + if TYPE_CHECKING: + assert item.uid + await self.coordinator.cookidoo.edit_ingredient_items_ownership( + [ + CookidooIngredientItem( + id=item.uid, + name="", + description="", + is_owned=item.status == TodoItemStatus.COMPLETED, + ) + ] + ) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_update_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + +class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity): + """A To-do List representation of the additional items in the Cookidoo Shopping List.""" + + _attr_translation_key = "additional_item_list" + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items" + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo items.""" + + return [ + TodoItem( + uid=item.id, + summary=item.name, + status=( + TodoItemStatus.COMPLETED + if item.is_owned + else TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self.coordinator.data.additional_items + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + + try: + if TYPE_CHECKING: + assert item.summary + await self.coordinator.cookidoo.add_additional_items([item.summary]) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_save_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + + try: + if TYPE_CHECKING: + assert item.uid + assert item.summary + new_item = CookidooAdditionalItem( + id=item.uid, + name=item.summary, + is_owned=item.status == TodoItemStatus.COMPLETED, + ) + await self.coordinator.cookidoo.edit_additional_items_ownership([new_item]) + await self.coordinator.cookidoo.edit_additional_items([new_item]) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_update_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item from the To-do list.""" + + try: + await self.coordinator.cookidoo.remove_additional_items(uids) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_delete_item_failed", + translation_placeholders={"count": str(len(uids))}, + ) from e + + await self.coordinator.async_refresh() diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index d3cb7122109..29be416d57e 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -55,7 +55,6 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index fb1f6467f4a..2c52fb43b9f 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -29,19 +29,19 @@ "services": { "decrement": { "name": "Decrement", - "description": "Decrements a counter." + "description": "Decrements a counter by its step size." }, "increment": { "name": "Increment", - "description": "Increments a counter." + "description": "Increments a counter by its step size." }, "reset": { "name": "Reset", - "description": "Resets a counter." + "description": "Resets a counter to its initial value." }, "set_value": { "name": "Set", - "description": "Sets the counter value.", + "description": "Sets the counter to a specific value.", "fields": { "value": { "name": "Value", diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 001bff51991..9ce526712f0 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: - if type(features) is int: # noqa: E721 - new_features = CoverEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features return features supported_features = ( diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index bf6e9204714..2a96098421a 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -49,7 +49,7 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow): cloud: CrownstoneCloud def __init__( - self, flow_type: str, create_entry_cb: Callable[..., ConfigFlowResult] + self, flow_type: str, create_entry_cb: Callable[[], ConfigFlowResult] ) -> None: """Set up flow instance.""" self.flow_type = flow_type diff --git a/homeassistant/components/cups/manifest.json b/homeassistant/components/cups/manifest.json index c4aa596f01e..c8f19236ce7 100644 --- a/homeassistant/components/cups/manifest.json +++ b/homeassistant/components/cups/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/cups", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["pycups==1.9.73"] + "requirements": ["pycups==2.0.4"] } diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 39e92ab1921..751683656f2 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -104,7 +104,6 @@ class DaikinClimate(DaikinEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes: list[str] _attr_swing_modes: list[str] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DaikinCoordinator) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index f6e9cb78efb..f794d97a9ba 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.7"], + "requirements": ["pydaikin==2.13.8"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/deako/__init__.py b/homeassistant/components/deako/__init__.py index fdcf09fad60..7a169defe01 100644 --- a/homeassistant/components/deako/__init__.py +++ b/homeassistant/components/deako/__init__.py @@ -4,8 +4,7 @@ from __future__ import annotations import logging -from pydeako.deako import Deako, DeviceListTimeout, FindDevicesTimeout -from pydeako.discover import DeakoDiscoverer +from pydeako import Deako, DeakoDiscoverer, FindDevicesError from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry @@ -30,12 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> boo await connection.connect() try: await connection.find_devices() - except DeviceListTimeout as exc: # device list never received - _LOGGER.warning("Device not responding to device list") - await connection.disconnect() - raise ConfigEntryNotReady(exc) from exc - except FindDevicesTimeout as exc: # total devices expected not received - _LOGGER.warning("Device not responding to device requests") + except FindDevicesError as exc: + _LOGGER.warning("Error finding devices: %s", exc) await connection.disconnect() raise ConfigEntryNotReady(exc) from exc diff --git a/homeassistant/components/deako/config_flow.py b/homeassistant/components/deako/config_flow.py index d0676fa81d9..273cbf2795e 100644 --- a/homeassistant/components/deako/config_flow.py +++ b/homeassistant/components/deako/config_flow.py @@ -1,6 +1,6 @@ """Config flow for deako.""" -from pydeako.discover import DeakoDiscoverer, DevicesNotFoundException +from pydeako import DeakoDiscoverer, DevicesNotFoundException from homeassistant.components import zeroconf from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py index c7ff8765402..75b01935c9a 100644 --- a/homeassistant/components/deako/light.py +++ b/homeassistant/components/deako/light.py @@ -2,7 +2,7 @@ from typing import Any -from pydeako.deako import Deako +from pydeako import Deako from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/deako/manifest.json b/homeassistant/components/deako/manifest.json index e3099439b9d..f4f4782530b 100644 --- a/homeassistant/components/deako/manifest.json +++ b/homeassistant/components/deako/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/deako", "iot_class": "local_polling", "loggers": ["pydeako"], - "requirements": ["pydeako==0.5.4"], + "requirements": ["pydeako==0.6.0"], "single_config_entry": true, "zeroconf": ["_deako._tcp.local."] } diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 1e31e002a81..078af8c67a5 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.6"] + "requirements": ["debugpy==1.8.11"] } diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 1e228dc6c48..690f943379d 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -101,7 +101,6 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): TYPE = CLIMATE_DOMAIN _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Thermostat, hub: DeconzHub) -> None: """Set up thermostat device.""" diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 48f29cf9b72..26e4d3328b8 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -65,7 +65,6 @@ class DeconzFan(DeconzDevice[Light], FanEntity): | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Light, hub: DeconzHub) -> None: """Set up fan.""" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 95a97959d5b..b1df32efc31 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -12,12 +12,14 @@ from pydeconz.models.light.light import Light, LightAlert, LightColorMode, Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, DOMAIN as LIGHT_DOMAIN, EFFECT_COLORLOOP, FLASH_LONG, @@ -30,7 +32,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import color_hs_to_xy +from homeassistant.util.color import ( + color_hs_to_xy, + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .entity import DeconzDevice @@ -187,6 +193,8 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( TYPE = LIGHT_DOMAIN _attr_color_mode = ColorMode.UNKNOWN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN def __init__(self, device: _LightDeviceT, hub: DeconzHub) -> None: """Set up light.""" @@ -256,9 +264,11 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( return self._device.brightness @property - def color_temp(self) -> int | None: + def color_temp_kelvin(self) -> int | None: """Return the CT color value.""" - return self._device.color_temp + if self._device.color_temp is None: + return None + return color_temperature_mired_to_kelvin(self._device.color_temp) @property def hs_color(self) -> tuple[float, float] | None: @@ -284,8 +294,10 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( if ATTR_BRIGHTNESS in kwargs: data["brightness"] = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: - data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + data["color_temperature"] = color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_HS_COLOR in kwargs: if ColorMode.XY in self._attr_supported_color_modes: @@ -338,14 +350,18 @@ class DeconzLight(DeconzBaseLight[Light]): """Representation of a deCONZ light.""" @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self._device.max_color_temp or super().max_mireds + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + if max_color_temp_mireds := self._device.max_color_temp: + return color_temperature_mired_to_kelvin(max_color_temp_mireds) + return super().min_color_temp_kelvin @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._device.min_color_temp or super().min_mireds + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + if min_color_temp_mireds := self._device.min_color_temp: + return color_temperature_mired_to_kelvin(min_color_temp_mireds) + return super().max_color_temp_kelvin @callback def async_update_callback(self) -> None: diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 5424591f021..d5b763caa5a 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -98,7 +98,6 @@ class DemoClimate(ClimateEntity): _attr_name = None _attr_should_poll = False _attr_translation_key = "ubercool" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 064ee3bb4f7..42e7f9e2434 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -100,7 +100,6 @@ class BaseDemoFan(FanEntity): _attr_should_poll = False _attr_translation_key = "demo" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index c859fef3b76..ec98a056b3e 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -7,12 +7,14 @@ from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_WHITE, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, LightEntityFeature, @@ -28,7 +30,7 @@ LIGHT_COLORS = [(56, 86), (345, 75)] LIGHT_EFFECT_LIST = ["rainbow", "none"] -LIGHT_TEMPS = [240, 380] +LIGHT_TEMPS = [4166, 2631] SUPPORT_DEMO = {ColorMode.HS, ColorMode.COLOR_TEMP} SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE} @@ -100,6 +102,9 @@ class DemoLight(LightEntity): _attr_name = None _attr_should_poll = False + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + def __init__( self, unique_id: str, @@ -185,8 +190,8 @@ class DemoLight(LightEntity): return self._rgbww_color @property - def color_temp(self) -> int: - """Return the CT color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" return self._ct @property @@ -216,9 +221,9 @@ class DemoLight(LightEntity): if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN in kwargs: self._color_mode = ColorMode.COLOR_TEMP - self._ct = kwargs[ATTR_COLOR_TEMP] + self._ct = kwargs[ATTR_COLOR_TEMP_KELVIN] if ATTR_EFFECT in kwargs: self._effect = kwargs[ATTR_EFFECT] diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index d4c3820d29e..3dd945ab82e 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -7,12 +7,8 @@ from typing import Any from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -91,16 +87,11 @@ class StateDemoVacuum(StateVacuumEntity): """Initialize the vacuum.""" self._attr_name = name self._attr_supported_features = supported_features - self._state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 self._battery_level = 100 - @property - def state(self) -> str: - """Return the current state of the vacuum.""" - return self._state - @property def battery_level(self) -> int: """Return the current battery level of the vacuum.""" @@ -123,33 +114,33 @@ class StateDemoVacuum(StateVacuumEntity): def start(self) -> None: """Start or resume the cleaning task.""" - if self._state != STATE_CLEANING: - self._state = STATE_CLEANING + if self._attr_activity != VacuumActivity.CLEANING: + self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 self._battery_level -= 1 self.schedule_update_ha_state() def pause(self) -> None: """Pause the cleaning task.""" - if self._state == STATE_CLEANING: - self._state = STATE_PAUSED + if self._attr_activity == VacuumActivity.CLEANING: + self._attr_activity = VacuumActivity.PAUSED self.schedule_update_ha_state() def stop(self, **kwargs: Any) -> None: """Stop the cleaning task, do not return to dock.""" - self._state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self.schedule_update_ha_state() def return_to_base(self, **kwargs: Any) -> None: """Return dock to charging base.""" - self._state = STATE_RETURNING + self._attr_activity = VacuumActivity.RETURNING self.schedule_update_ha_state() event.call_later(self.hass, 30, self.__set_state_to_dock) def clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - self._state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 self._battery_level -= 1 self.schedule_update_ha_state() @@ -167,12 +158,12 @@ class StateDemoVacuum(StateVacuumEntity): "persistent_notification", service_data={"message": "I'm here!", "title": "Locate request"}, ) - self._state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self.async_write_ha_state() async def async_clean_spot(self, **kwargs: Any) -> None: """Locate the vacuum's position.""" - self._state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING self.async_write_ha_state() async def async_send_command( @@ -182,9 +173,9 @@ class StateDemoVacuum(StateVacuumEntity): **kwargs: Any, ) -> None: """Send a command to the vacuum.""" - self._state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self.async_write_ha_state() def __set_state_to_dock(self, _: datetime) -> None: - self._state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 7755e0f22b4..e86b7b753c8 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] @@ -102,5 +102,4 @@ def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Myd mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] mydevolo.password = conf[CONF_PASSWORD] - mydevolo.url = conf.get(CONF_MYDEVOLO, DEFAULT_MYDEVOLO) return mydevolo diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 29177ae2437..1f407eb6804 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -56,7 +56,6 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit _attr_precision = PRECISION_TENTHS _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index bfb083e0c44..e15204af7c2 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from . import configure_mydevolo -from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, DOMAIN, SUPPORTED_MODEL_TYPES +from .const import DOMAIN, SUPPORTED_MODEL_TYPES from .exceptions import CredentialsInvalid, UuidChanged @@ -35,14 +35,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - self._url = DEFAULT_MYDEVOLO async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - if self.show_advanced_options: - self.data_schema[vol.Required(CONF_MYDEVOLO, default=self._url)] = str if user_input is None: return self._show_form(step_id="user") try: @@ -78,7 +75,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauthentication.""" self._reauth_entry = self._get_reauth_entry() - self._url = entry_data[CONF_MYDEVOLO] self.data_schema = { vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, @@ -104,7 +100,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Connect to mydevolo.""" - user_input[CONF_MYDEVOLO] = user_input.get(CONF_MYDEVOLO, self._url) mydevolo = configure_mydevolo(conf=user_input) credentials_valid = await self.hass.async_add_executor_job( mydevolo.credentials_valid @@ -121,7 +116,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): data={ CONF_PASSWORD: mydevolo.password, CONF_USERNAME: mydevolo.user, - CONF_MYDEVOLO: mydevolo.url, }, ) diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index eb48a6d269e..bd2282ad99f 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -5,7 +5,6 @@ import re from homeassistant.const import Platform DOMAIN = "devolo_home_control" -DEFAULT_MYDEVOLO = "https://www.mydevolo.com" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -15,6 +14,5 @@ PLATFORMS = [ Platform.SIREN, Platform.SWITCH, ] -CONF_MYDEVOLO = "mydevolo_url" GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") SUPPORTED_MODEL_TYPES = ["2600", "2601"] diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index eeae9aa2e2f..1eaf64564c2 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -12,15 +12,21 @@ "user": { "data": { "username": "Email / devolo ID", - "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo URL" + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Email address you used to register the central unit at mydevolo.", + "password": "Password of your mydevolo account." } }, "zeroconf_confirm": { "data": { "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "[%key:component::devolo_home_control::config::step::user::data::mydevolo_url%]" + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]", + "password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]" } } } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index a069c32be04..213e948bafb 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -549,7 +549,7 @@ async def async_setup_entry( dsmr_version = entry.data[CONF_DSMR_VERSION] entities: list[DSMREntity] = [] initialized: bool = False - add_entities_handler: Callable[..., None] | None + add_entities_handler: Callable[[], None] | None @callback def init_async_add_entities(telegram: Telegram) -> None: diff --git a/homeassistant/components/dte_energy_bridge/__init__.py b/homeassistant/components/dte_energy_bridge/__init__.py deleted file mode 100644 index 2525d047bce..00000000000 --- a/homeassistant/components/dte_energy_bridge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The dte_energy_bridge component.""" diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json deleted file mode 100644 index 8285469a745..00000000000 --- a/homeassistant/components/dte_energy_bridge/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "dte_energy_bridge", - "name": "DTE Energy Bridge", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge", - "iot_class": "local_polling", - "quality_scale": "legacy" -} diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py deleted file mode 100644 index a0b9253034e..00000000000 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Support for monitoring energy usage using the DTE energy bridge.""" - -from __future__ import annotations - -from http import HTTPStatus -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.const import CONF_NAME, UnitOfPower -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_IP_ADDRESS = "ip" -CONF_VERSION = "version" - -DEFAULT_NAME = "Current Energy Usage" -DEFAULT_VERSION = 1 -DOMAIN = "dte_energy_bridge" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.All( - vol.Coerce(int), vol.Any(1, 2) - ), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the DTE energy bridge sensor.""" - create_issue( - hass, - DOMAIN, - "deprecated_integration", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_integration", - translation_placeholders={"domain": DOMAIN}, - ) - - name = config[CONF_NAME] - ip_address = config[CONF_IP_ADDRESS] - version = config[CONF_VERSION] - - add_entities([DteEnergyBridgeSensor(ip_address, name, version)], True) - - -class DteEnergyBridgeSensor(SensorEntity): - """Implementation of the DTE Energy Bridge sensors.""" - - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = UnitOfPower.KILO_WATT - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__(self, ip_address, name, version): - """Initialize the sensor.""" - self._version = version - - if self._version == 1: - self._url = f"http://{ip_address}/instantaneousdemand" - elif self._version == 2: - self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand" - - self._attr_name = name - - def update(self) -> None: - """Get the energy usage data from the DTE energy bridge.""" - try: - response = requests.get(self._url, timeout=5) - except (requests.exceptions.RequestException, ValueError): - _LOGGER.warning( - "Could not update status for DTE Energy Bridge (%s)", self._attr_name - ) - return - - if response.status_code != HTTPStatus.OK: - _LOGGER.warning( - "Invalid status_code from DTE Energy Bridge: %s (%s)", - response.status_code, - self._attr_name, - ) - return - - response_split = response.text.split() - - if len(response_split) != 2: - _LOGGER.warning( - 'Invalid response from DTE Energy Bridge: "%s" (%s)', - response.text, - self._attr_name, - ) - return - - val = float(response_split[0]) - - # A workaround for a bug in the DTE energy bridge. - # The returned value can randomly be in W or kW. Checking for a - # a decimal seems to be a reliable way to determine the units. - # Limiting to version 1 because version 2 apparently always returns - # values in the format 000000.000 kW, but the scaling is Watts - # NOT kWatts - if self._version == 1 and "." in response_split[0]: - self._attr_native_value = val - else: - self._attr_native_value = val / 1000 diff --git a/homeassistant/components/dte_energy_bridge/strings.json b/homeassistant/components/dte_energy_bridge/strings.json deleted file mode 100644 index f75867b8faa..00000000000 --- a/homeassistant/components/dte_energy_bridge/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "deprecated_integration": { - "title": "The DTE Energy Bridge integration will be removed", - "description": "The DTE Energy Bridge integration will be removed as new users can't get any supported devices, and the integration will fail as soon as a current device gets internet access.\n\n Please remove all `{domain}`platform sensors from your configuration and restart Home Assistant." - } - } -} diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 77b602c8716..0355d2855d3 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -57,7 +57,6 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _attr_hvac_modes = list(HVACMODE_REVERSE) _attr_preset_modes = list(PRESETMODES) _attr_translation_key = "duotecno" - _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index e520631158a..0548431f09d 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,10 +9,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import EasyEnergyDataUpdateCoordinator +from .coordinator import EasyEnergyConfigEntry, EasyEnergyDataUpdateCoordinator from .services import async_setup_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -25,25 +24,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> bool: """Set up easyEnergy from a config entry.""" - coordinator = EasyEnergyDataUpdateCoordinator(hass) + coordinator = EasyEnergyDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: await coordinator.easyenergy.close() raise - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> bool: """Unload easyEnergy config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py index 8c1c593af93..e36bdf188ee 100644 --- a/homeassistant/components/easyenergy/coordinator.py +++ b/homeassistant/components/easyenergy/coordinator.py @@ -21,6 +21,8 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR +type EasyEnergyConfigEntry = ConfigEntry[EasyEnergyDataUpdateCoordinator] + class EasyEnergyData(NamedTuple): """Class for defining data in dict.""" @@ -33,15 +35,16 @@ class EasyEnergyData(NamedTuple): class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): """Class to manage fetching easyEnergy data from single endpoint.""" - config_entry: ConfigEntry + config_entry: EasyEnergyConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> None: """Initialize global easyEnergy data updater.""" super().__init__( hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + config_entry=entry, ) self.easyenergy = EasyEnergy(session=async_get_clientsession(hass)) diff --git a/homeassistant/components/easyenergy/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py index d6912e1c926..64f30ba61fd 100644 --- a/homeassistant/components/easyenergy/diagnostics.py +++ b/homeassistant/components/easyenergy/diagnostics.py @@ -5,12 +5,9 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import EasyEnergyDataUpdateCoordinator -from .const import DOMAIN -from .coordinator import EasyEnergyData +from .coordinator import EasyEnergyConfigEntry, EasyEnergyData def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: @@ -32,41 +29,42 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: EasyEnergyConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator_data = entry.runtime_data.data + energy_today = coordinator_data.energy_today return { "entry": { "title": entry.title, }, "energy_usage": { - "current_hour_price": coordinator.data.energy_today.current_usage_price, - "next_hour_price": coordinator.data.energy_today.price_at_time( - coordinator.data.energy_today.utcnow() + timedelta(hours=1) + "current_hour_price": energy_today.current_usage_price, + "next_hour_price": energy_today.price_at_time( + energy_today.utcnow() + timedelta(hours=1) ), - "average_price": coordinator.data.energy_today.average_usage_price, - "max_price": coordinator.data.energy_today.extreme_usage_prices[1], - "min_price": coordinator.data.energy_today.extreme_usage_prices[0], - "highest_price_time": coordinator.data.energy_today.highest_usage_price_time, - "lowest_price_time": coordinator.data.energy_today.lowest_usage_price_time, - "percentage_of_max": coordinator.data.energy_today.pct_of_max_usage, + "average_price": energy_today.average_usage_price, + "max_price": energy_today.extreme_usage_prices[1], + "min_price": energy_today.extreme_usage_prices[0], + "highest_price_time": energy_today.highest_usage_price_time, + "lowest_price_time": energy_today.lowest_usage_price_time, + "percentage_of_max": energy_today.pct_of_max_usage, }, "energy_return": { - "current_hour_price": coordinator.data.energy_today.current_return_price, - "next_hour_price": coordinator.data.energy_today.price_at_time( - coordinator.data.energy_today.utcnow() + timedelta(hours=1), "return" + "current_hour_price": energy_today.current_return_price, + "next_hour_price": energy_today.price_at_time( + energy_today.utcnow() + timedelta(hours=1), "return" ), - "average_price": coordinator.data.energy_today.average_return_price, - "max_price": coordinator.data.energy_today.extreme_return_prices[1], - "min_price": coordinator.data.energy_today.extreme_return_prices[0], - "highest_price_time": coordinator.data.energy_today.highest_return_price_time, - "lowest_price_time": coordinator.data.energy_today.lowest_return_price_time, - "percentage_of_max": coordinator.data.energy_today.pct_of_max_return, + "average_price": energy_today.average_return_price, + "max_price": energy_today.extreme_return_prices[1], + "min_price": energy_today.extreme_return_prices[0], + "highest_price_time": energy_today.highest_return_price_time, + "lowest_price_time": energy_today.lowest_return_price_time, + "percentage_of_max": energy_today.pct_of_max_return, }, "gas": { - "current_hour_price": get_gas_price(coordinator.data, 0), - "next_hour_price": get_gas_price(coordinator.data, 1), + "current_hour_price": get_gas_price(coordinator_data, 0), + "next_hour_price": get_gas_price(coordinator_data, 1), }, } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 65fe2558d46..6976a38da49 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CURRENCY_EURO, PERCENTAGE, @@ -27,7 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES -from .coordinator import EasyEnergyData, EasyEnergyDataUpdateCoordinator +from .coordinator import ( + EasyEnergyConfigEntry, + EasyEnergyData, + EasyEnergyDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -208,10 +211,12 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EasyEnergyConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up easyEnergy sensors based on a config entry.""" - coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( EasyEnergySensorEntity(coordinator=coordinator, description=description) for description in SENSORS diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 5b80cfafd08..f5ee89d5325 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -10,7 +10,7 @@ from typing import Final from easyenergy import Electricity, Gas, VatOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -23,7 +23,7 @@ from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import EasyEnergyDataUpdateCoordinator +from .coordinator import EasyEnergyConfigEntry, EasyEnergyDataUpdateCoordinator ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" @@ -86,12 +86,12 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp } -def __get_coordinator( - hass: HomeAssistant, call: ServiceCall -) -> EasyEnergyDataUpdateCoordinator: +def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator: """Get the coordinator from the entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + entry: EasyEnergyConfigEntry | None = call.hass.config_entries.async_get_entry( + entry_id + ) if not entry: raise ServiceValidationError( @@ -110,18 +110,16 @@ def __get_coordinator( }, ) - coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry_id] - return coordinator + return entry.runtime_data async def __get_prices( call: ServiceCall, *, - hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: """Get prices from easyEnergy.""" - coordinator = __get_coordinator(hass, call) + coordinator = __get_coordinator(call) start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -157,21 +155,21 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.GAS), + partial(__get_prices, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_USAGE_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_USAGE), + partial(__get_prices, price_type=PriceType.ENERGY_USAGE), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_RETURN_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_RETURN), + partial(__get_prices, price_type=PriceType.ENERGY_RETURN), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 6a9ec0d5db9..709926d8496 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -353,7 +353,6 @@ class Thermostat(ClimateEntity): _attr_fan_modes = [FAN_AUTO, FAN_ON] _attr_name = None _attr_has_entity_name = True - _enable_turn_on_off_backwards_compatibility = False _attr_translation_key = "ecobee" def __init__( diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index bac123bf206..cdf82f6817f 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -68,7 +68,6 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat): """Initialize.""" diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 3a70ab2af5b..69dd0f0813f 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -99,8 +99,8 @@ class EcovacsController: for device_config in devices.not_supported: _LOGGER.warning( ( - 'Device "%s" not supported. Please add support for it to ' - "https://github.com/DeebotUniverse/client.py: %s" + 'Device "%s" not supported. More information at ' + "https://github.com/DeebotUniverse/client.py/issues/612: %s" ), device_config["deviceName"], device_config, diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 4a43489ff24..271f9ee8dcd 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==9.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==9.4.0"] } diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 0d14267e08d..dde4fd64b56 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -13,14 +13,9 @@ from deebot_client.models import CleanAction, CleanMode, Room, State import sucks from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, SupportsResponse @@ -123,22 +118,22 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): self.schedule_update_ha_state() @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the state of the vacuum cleaner.""" if self.error is not None: - return STATE_ERROR + return VacuumActivity.ERROR if self.device.is_cleaning: - return STATE_CLEANING + return VacuumActivity.CLEANING if self.device.is_charging: - return STATE_DOCKED + return VacuumActivity.DOCKED if self.device.vacuum_status == sucks.CLEAN_MODE_STOP: - return STATE_IDLE + return VacuumActivity.IDLE if self.device.vacuum_status == sucks.CHARGE_MODE_RETURNING: - return STATE_RETURNING + return VacuumActivity.RETURNING return None @@ -202,7 +197,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self.state == STATE_CLEANING: + if self.state == VacuumActivity.CLEANING: self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) def send_command( @@ -225,12 +220,12 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): _STATE_TO_VACUUM_STATE = { - State.IDLE: STATE_IDLE, - State.CLEANING: STATE_CLEANING, - State.RETURNING: STATE_RETURNING, - State.DOCKED: STATE_DOCKED, - State.ERROR: STATE_ERROR, - State.PAUSED: STATE_PAUSED, + State.IDLE: VacuumActivity.IDLE, + State.CLEANING: VacuumActivity.CLEANING, + State.RETURNING: VacuumActivity.RETURNING, + State.DOCKED: VacuumActivity.DOCKED, + State.ERROR: VacuumActivity.ERROR, + State.PAUSED: VacuumActivity.PAUSED, } _ATTR_ROOMS = "rooms" @@ -284,7 +279,7 @@ class EcovacsVacuum( self.async_write_ha_state() async def on_status(event: StateEvent) -> None: - self._attr_state = _STATE_TO_VACUUM_STATE[event.state] + self._attr_activity = _STATE_TO_VACUUM_STATE[event.state] self.async_write_ha_state() self._subscribe(self._capability.battery.event, on_battery) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py new file mode 100644 index 00000000000..cf08f45bed5 --- /dev/null +++ b/homeassistant/components/eheimdigital/__init__.py @@ -0,0 +1,51 @@ +"""The EHEIM Digital integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN +from .coordinator import EheimDigitalUpdateCoordinator + +PLATFORMS = [Platform.LIGHT] + +type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] + + +async def async_setup_entry( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> bool: + """Set up EHEIM Digital from a config entry.""" + + coordinator = EheimDigitalUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> bool: + """Unload a config entry.""" + await entry.runtime_data.hub.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: EheimDigitalConfigEntry, + device_entry: DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.hub.devices + ) diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py new file mode 100644 index 00000000000..6994c6f65b5 --- /dev/null +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for EHEIM Digital.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from aiohttp import ClientError +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.hub import EheimDigitalHub +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +CONFIG_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST, default="eheimdigital.local"): selector.TextSelector()} +) + + +class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): + """The EHEIM Digital config flow.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} + self.main_device_added_event = asyncio.Event() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + + self._async_abort_entries_match(self.data) + + hub = EheimDigitalHub( + host=host, + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await hub.close() + except (ClientError, TimeoutError): + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 + return self.async_abort(reason="unknown") + await self.async_set_unique_id(hub.main.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_HOST], + data={CONF_HOST: self.data[CONF_HOST]}, + ) + + self._set_confirm_only() + return self.async_show_form(step_id="discovery_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + if user_input is None: + return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA) + + self._async_abort_entries_match(user_input) + errors: dict[str, str] = {} + hub = EheimDigitalHub( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await self.async_set_unique_id( + hub.main.mac_address, raise_on_progress=False + ) + await hub.close() + except (ClientError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + LOGGER.exception("Unknown exception occurred") + else: + self._abort_if_unique_id_configured() + return self.async_create_entry(data=user_input, title=user_input[CONF_HOST]) + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=CONFIG_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py new file mode 100644 index 00000000000..5ed9303be40 --- /dev/null +++ b/homeassistant/components/eheimdigital/const.py @@ -0,0 +1,17 @@ +"""Constants for the EHEIM Digital integration.""" + +from logging import Logger, getLogger + +from eheimdigital.types import LightMode + +from homeassistant.components.light import EFFECT_OFF + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "eheimdigital" + +EFFECT_DAYCL_MODE = "daycl_mode" + +EFFECT_TO_LIGHT_MODE = { + EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, + EFFECT_OFF: LightMode.MAN_MODE, +} diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py new file mode 100644 index 00000000000..f122a1227c5 --- /dev/null +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -0,0 +1,78 @@ +"""Data update coordinator for the EHEIM Digital integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any + +from aiohttp import ClientError +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.hub import EheimDigitalHub +from eheimdigital.types import EheimDeviceType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] + + +class EheimDigitalUpdateCoordinator( + DataUpdateCoordinator[dict[str, EheimDigitalDevice]] +): + """The EHEIM Digital data update coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the EHEIM Digital data update coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + self.hub = EheimDigitalHub( + host=self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + loop=hass.loop, + receive_callback=self._async_receive_callback, + device_found_callback=self._async_device_found, + ) + self.known_devices: set[str] = set() + self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set() + + def add_platform_callback( + self, + async_setup_device_entities: AsyncSetupDeviceEntitiesCallback, + ) -> None: + """Add the setup callbacks from a specific platform.""" + self.platform_callbacks.add(async_setup_device_entities) + + async def _async_device_found( + self, device_address: str, device_type: EheimDeviceType + ) -> None: + """Set up a new device found. + + This function is called from the library whenever a new device is added. + """ + + if device_address not in self.known_devices: + for platform_callback in self.platform_callbacks: + await platform_callback(device_address) + + async def _async_receive_callback(self) -> None: + self.async_set_updated_data(self.hub.devices) + + async def _async_setup(self) -> None: + await self.hub.connect() + await self.hub.update() + + async def _async_update_data(self) -> dict[str, EheimDigitalDevice]: + try: + await self.hub.update() + except ClientError as ex: + raise UpdateFailed from ex + return self.data diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py new file mode 100644 index 00000000000..c0f91a4b798 --- /dev/null +++ b/homeassistant/components/eheimdigital/entity.py @@ -0,0 +1,53 @@ +"""Base entity for EHEIM Digital.""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from eheimdigital.device import EheimDigitalDevice + +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EheimDigitalUpdateCoordinator + + +class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( + CoordinatorEntity[EheimDigitalUpdateCoordinator], ABC +): + """Represent a EHEIM Digital entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: EheimDigitalUpdateCoordinator, device: _DeviceT + ) -> None: + """Initialize a EHEIM Digital entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + # At this point at least one device is found and so there is always a main device set + assert isinstance(coordinator.hub.main, EheimDigitalDevice) + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", + name=device.name, + connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, + manufacturer="EHEIM", + model=device.device_type.model_name, + identifiers={(DOMAIN, device.mac_address)}, + suggested_area=device.aquarium_name, + sw_version=device.sw_version, + via_device=(DOMAIN, coordinator.hub.main.mac_address), + ) + self._device = device + self._device_address = device.mac_address + + @abstractmethod + def _async_update_attrs(self) -> None: ... + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py new file mode 100644 index 00000000000..a119e0bda8d --- /dev/null +++ b/homeassistant/components/eheimdigital/light.py @@ -0,0 +1,127 @@ +"""EHEIM Digital lights.""" + +from typing import Any + +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.types import EheimDigitalClientError, LightMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + EFFECT_OFF, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from . import EheimDigitalConfigEntry +from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE +from .coordinator import EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +BRIGHTNESS_SCALE = (1, 100) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + async def async_setup_device_entities(device_address: str) -> None: + """Set up the light entities for a device.""" + device = coordinator.hub.devices[device_address] + entities: list[EheimDigitalClassicLEDControlLight] = [] + + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight(coordinator, device, channel) + ) + coordinator.known_devices.add(device.mac_address) + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + + for device_address in entry.runtime_data.hub.devices: + await async_setup_device_entities(device_address) + + +class EheimDigitalClassicLEDControlLight( + EheimDigitalEntity[EheimDigitalClassicLEDControl], LightEntity +): + """Represent a EHEIM Digital classicLEDcontrol light.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_effect_list = [EFFECT_DAYCL_MODE] + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "channel" + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: EheimDigitalClassicLEDControl, + channel: int, + ) -> None: + """Initialize an EHEIM Digital classicLEDcontrol light entity.""" + super().__init__(coordinator, device) + self._channel = channel + self._attr_translation_placeholders = {"channel_id": str(channel)} + self._attr_unique_id = f"{self._device_address}_{channel}" + self._async_update_attrs() + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self._device.light_level[self._channel] is not None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + if ATTR_EFFECT in kwargs: + await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]]) + return + if ATTR_BRIGHTNESS in kwargs: + if self._device.light_mode == LightMode.DAYCL_MODE: + await self._device.set_light_mode(LightMode.MAN_MODE) + try: + await self._device.turn_on( + int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])), + self._channel, + ) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + if self._device.light_mode == LightMode.DAYCL_MODE: + await self._device.set_light_mode(LightMode.MAN_MODE) + try: + await self._device.turn_off(self._channel) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + def _async_update_attrs(self) -> None: + light_level = self._device.light_level[self._channel] + + self._attr_is_on = light_level > 0 if light_level is not None else None + self._attr_brightness = ( + value_to_brightness(BRIGHTNESS_SCALE, light_level) + if light_level is not None + else None + ) + self._attr_effect = ( + EFFECT_DAYCL_MODE + if self._device.light_mode == LightMode.DAYCL_MODE + else EFFECT_OFF + ) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json new file mode 100644 index 00000000000..159aecd6b6c --- /dev/null +++ b/homeassistant/components/eheimdigital/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "eheimdigital", + "name": "EHEIM Digital", + "codeowners": ["@autinerd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/eheimdigital", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["eheimdigital"], + "quality_scale": "bronze", + "requirements": ["eheimdigital==1.0.3"], + "zeroconf": [ + { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } + ] +} diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml new file mode 100644 index 00000000000..a56551a14f6 --- /dev/null +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions implemented. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions implemented. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration doesn't have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration requires no authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json new file mode 100644 index 00000000000..0e6fa6a0814 --- /dev/null +++ b/homeassistant/components/eheimdigital/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The host or IP address of your main device. Only needed to change if 'eheimdigital' doesn't work." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "light": { + "channel": { + "name": "Channel {channel_id}", + "state_attributes": { + "effect": { + "state": { + "daycl_mode": "Daycycle mode" + } + } + } + } + } + } +} diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 81a07545a30..04e4742554b 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -111,7 +111,6 @@ class ElectraClimateEntity(ClimateEntity): _attr_hvac_modes = ELECTRA_MODES _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index 7da4802e98a..84b2b61b8ed 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -4,14 +4,13 @@ from __future__ import annotations from dataclasses import dataclass -from elevenlabs import Model -from elevenlabs.client import AsyncElevenLabs +from elevenlabs import AsyncElevenLabs, Model from elevenlabs.core import ApiError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MODEL @@ -50,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) try: model = await get_model_by_id(client, model_id) except ApiError as err: - raise ConfigEntryError("Auth failed") from err + raise ConfigEntryAuthFailed("Auth failed") from err if model is None or (not model.languages): raise ConfigEntryError("Model could not be resolved") diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 227150a0f4e..60df79d6eaa 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -5,16 +5,11 @@ from __future__ import annotations import logging from typing import Any -from elevenlabs.client import AsyncElevenLabs +from elevenlabs import AsyncElevenLabs from elevenlabs.core import ApiError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client @@ -24,6 +19,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) +from . import EleventLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, @@ -96,7 +92,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: EleventLabsConfigEntry, ) -> OptionsFlow: """Create the options flow.""" return ElevenLabsOptionsFlow(config_entry) @@ -105,7 +101,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): class ElevenLabsOptionsFlow(OptionsFlow): """ElevenLabs options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: EleventLabsConfigEntry) -> None: """Initialize options flow.""" self.api_key: str = config_entry.data[CONF_API_KEY] # id -> name diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json index 968ea7b688a..eb6df09149a 100644 --- a/homeassistant/components/elevenlabs/manifest.json +++ b/homeassistant/components/elevenlabs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["elevenlabs"], - "requirements": ["elevenlabs==1.6.1"] + "requirements": ["elevenlabs==1.9.0"] } diff --git a/homeassistant/components/elevenlabs/quality_scale.yaml b/homeassistant/components/elevenlabs/quality_scale.yaml new file mode 100644 index 00000000000..49f0d7518f5 --- /dev/null +++ b/homeassistant/components/elevenlabs/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: done + comment: > + Only entity services + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: > + We should have every test end in either ABORT or CREATE_ENTRY. + test_invalid_api_key should assert the kind of error that is raised. + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: > + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: todo + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: + status: exempt + comment: > + There is no state in the TTS platform and we can't check poll if the TTS service is available. + action-exceptions: done + reauthentication-flow: todo + parallel-updates: done + test-coverage: todo + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: todo + + # Gold + entity-translations: todo + entity-device-class: + status: exempt + comment: There is no device class for Text To Speech entities. + devices: done + entity-category: done + entity-disabled-by-default: todo + discovery: + status: exempt + comment: > + This is not possible because there is no physical device. + stale-devices: + status: exempt + comment: > + This is not possible because there is no physical device. + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: todo + comment: > + I imagine this could be useful if the default voice is deleted from voice lab. + dynamic-devices: + status: exempt + comment: | + This is not possible because there is no physical device. + discovery-update-info: + status: exempt + comment: > + This is not needed because there are no physical devices. + repair-issues: todo + docs-use-cases: done + docs-supported-devices: + status: exempt + comment: > + This integration does not support any devices. + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index efc2154882a..c96a7161b72 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -6,7 +6,7 @@ import logging from types import MappingProxyType from typing import Any -from elevenlabs.client import AsyncElevenLabs +from elevenlabs import AsyncElevenLabs from elevenlabs.core import ApiError from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings @@ -16,6 +16,7 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -38,6 +39,7 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: @@ -84,6 +86,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): """The ElevenLabs API entity.""" _attr_supported_options = [ATTR_VOICE] + _attr_entity_category = EntityCategory.CONFIG def __init__( self, diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 2d8446c3b76..1b1ff9948c9 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -1,17 +1,14 @@ """Support for Elgato Lights.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] -type ElgatorConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] - -async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool: """Set up Elgato Light from a config entry.""" coordinator = ElgatoDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -22,6 +19,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool: """Unload Elgato Light config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index aefff0b750b..505eff36b44 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -18,10 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElgatorConfigEntry -from .coordinator import ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class ElgatoButtonEntityDescription(ButtonEntityDescription): @@ -48,7 +49,7 @@ BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato button based on a config entry.""" diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 5329fcee90a..e20afc73a2d 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -34,7 +34,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_show_setup_form() self.host = user_input[CONF_HOST] - self.port = user_input[CONF_PORT] try: await self._get_elgato_serial_number(raise_on_progress=False) @@ -49,7 +48,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): """Handle zeroconf discovery.""" self.host = discovery_info.host self.mac = discovery_info.properties.get("id") - self.port = discovery_info.port or 9123 try: await self._get_elgato_serial_number() @@ -81,7 +79,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional(CONF_PORT, default=9123): int, } ), errors=errors or {}, @@ -93,7 +90,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): title=self.serial_number, data={ CONF_HOST: self.host, - CONF_PORT: self.port, CONF_MAC: self.mac, }, ) @@ -103,7 +99,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass) elgato = Elgato( host=self.host, - port=self.port, session=session, ) info = await elgato.info() @@ -113,7 +108,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): info.serial_number, raise_on_progress=raise_on_progress ) self._abort_if_unique_id_configured( - updates={CONF_HOST: self.host, CONF_PORT: self.port, CONF_MAC: self.mac} + updates={CONF_HOST: self.host, CONF_MAC: self.mac} ) self.serial_number = info.serial_number diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index c2bc79491a1..5e1ba0a6494 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -5,13 +5,15 @@ from dataclasses import dataclass from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type ElgatoConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] + @dataclass class ElgatoData: @@ -26,20 +28,20 @@ class ElgatoData: class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): """Class to manage fetching Elgato data.""" - config_entry: ConfigEntry + config_entry: ElgatoConfigEntry has_battery: bool | None = None - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None: """Initialize the coordinator.""" self.config_entry = entry self.client = Elgato( entry.data[CONF_HOST], - port=entry.data[CONF_PORT], session=async_get_clientsession(hass), ) super().__init__( hass, LOGGER, + config_entry=entry, name=f"{DOMAIN}_{entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py index ac3ea0a155d..4e1b9d4cfdd 100644 --- a/homeassistant/components/elgato/diagnostics.py +++ b/homeassistant/components/elgato/diagnostics.py @@ -6,11 +6,11 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import ElgatorConfigEntry +from .coordinator import ElgatoConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ElgatorConfigEntry + hass: HomeAssistant, entry: ElgatoConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index a62a26f21d3..990a0606fce 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -8,7 +8,7 @@ from elgato import ElgatoError from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -19,10 +19,10 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) +from homeassistant.util import color as color_util -from . import ElgatorConfigEntry from .const import SERVICE_IDENTIFY -from .coordinator import ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity PARALLEL_UPDATES = 1 @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato Light based on a config entry.""" @@ -49,8 +49,8 @@ class ElgatoLight(ElgatoEntity, LightEntity): """Defines an Elgato Light.""" _attr_name = None - _attr_min_mireds = 143 - _attr_max_mireds = 344 + _attr_min_color_temp_kelvin = 2900 # 344 Mireds + _attr_max_color_temp_kelvin = 7000 # 143 Mireds def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None: """Initialize Elgato Light.""" @@ -69,8 +69,8 @@ class ElgatoLight(ElgatoEntity, LightEntity): or self.coordinator.data.state.hue is not None ): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} - self._attr_min_mireds = 153 - self._attr_max_mireds = 285 + self._attr_min_color_temp_kelvin = 3500 # 285 Mireds + self._attr_max_color_temp_kelvin = 6500 # 153 Mireds @property def brightness(self) -> int | None: @@ -78,9 +78,11 @@ class ElgatoLight(ElgatoEntity, LightEntity): return round((self.coordinator.data.state.brightness * 255) / 100) @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self.coordinator.data.state.temperature + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if (mired_temperature := self.coordinator.data.state.temperature) is None: + return None + return color_util.color_temperature_mired_to_kelvin(mired_temperature) @property def color_mode(self) -> str | None: @@ -116,7 +118,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - temperature = kwargs.get(ATTR_COLOR_TEMP) + temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hue = None saturation = None @@ -133,12 +135,18 @@ class ElgatoLight(ElgatoEntity, LightEntity): if ( brightness and ATTR_HS_COLOR not in kwargs - and ATTR_COLOR_TEMP not in kwargs + and ATTR_COLOR_TEMP_KELVIN not in kwargs and self.supported_color_modes and ColorMode.HS in self.supported_color_modes and self.color_mode == ColorMode.COLOR_TEMP ): - temperature = self.color_temp + temperature_kelvin = self.color_temp_kelvin + + temperature = ( + None + if temperature_kelvin is None + else color_util.color_temperature_kelvin_to_mired(temperature_kelvin) + ) try: await self.coordinator.client.light( diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index 2910bdb4473..531f0447f70 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -5,10 +5,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - The data_description for port is missing. + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index f794d26cf7f..529d2f7c76e 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -21,10 +21,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElgatorConfigEntry -from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ElgatoSensorEntityDescription(SensorEntityDescription): @@ -101,7 +103,7 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato sensor based on a config entry.""" diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 6e1031c8ddf..727b8ee7024 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -5,8 +5,7 @@ "user": { "description": "Set up your Elgato Light to integrate with Home Assistant.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "The hostname or IP address of your Elgato device." diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index fe177616034..3b2420b0ace 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -14,10 +14,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElgatorConfigEntry -from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class ElgatoSwitchEntityDescription(SwitchEntityDescription): @@ -52,7 +53,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato switches based on a config entry.""" diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index bf5650f237b..1448acc6079 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -90,7 +90,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes = [FAN_AUTO, FAN_ON] _element: Thermostat - _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 88e61e36a68..18350e45efe 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -35,7 +35,7 @@ def check_local_version_supported(api_version: str | None) -> bool: class DirectPanel(PanelEntry): """Helper class for wrapping a directly accessed Elmax Panel.""" - def __init__(self, panel_uri): + def __init__(self, panel_uri) -> None: """Construct the object.""" super().__init__(panel_uri, True, {}) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index bf479e997ef..3bb01efd3d5 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -203,7 +203,7 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_direct(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Handle the direct setup step.""" - self._selected_mode = CONF_ELMAX_MODE_CLOUD + self._selected_mode = CONF_ELMAX_MODE_DIRECT if user_input is None: return self.async_show_form( step_id=CONF_ELMAX_MODE_DIRECT, diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index a53c28c5f33..403bc51dbff 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -121,13 +121,13 @@ class ElmaxCover(ElmaxEntity, CoverEntity): else: _LOGGER.debug("Ignoring stop request as the cover is IDLE") - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.coordinator.http_client.execute_command( endpoint_id=self._device.endpoint_id, command=CoverCommand.UP ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.coordinator.http_client.execute_command( endpoint_id=self._device.endpoint_id, command=CoverCommand.DOWN diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index efa97a9f6b9..dfa20326d0c 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax-api==0.0.6.1"], + "requirements": ["elmax-api==0.0.6.3"], "zeroconf": [ { "type": "_elmax-ssl._tcp.local." diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 9273c24c7dc..291ecad0bd3 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -317,7 +317,7 @@ async def async_setup_entry( EmonCmsSensor( coordinator, unique_id, - elem["unit"], + elem.get("unit"), name, idx, ) @@ -353,6 +353,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): self.entity_description = description else: self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_name = f"{name} {elem[FEED_NAME]}" self._update_attributes(elem) def _update_attributes(self, elem: dict[str, Any]) -> None: diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 8194d31823d..e13112f20bb 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -39,7 +39,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.humidifier import ATTR_HUMIDITY, SERVICE_SET_HUMIDITY from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -67,6 +67,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, State from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util import color as color_util from homeassistant.util.json import json_loads from homeassistant.util.network import is_local @@ -500,7 +501,11 @@ class HueOneLightChangeView(HomeAssistantView): light.color_temp_supported(color_modes) and parsed[STATE_COLOR_TEMP] is not None ): - data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP] + data[ATTR_COLOR_TEMP_KELVIN] = ( + color_util.color_temperature_mired_to_kelvin( + parsed[STATE_COLOR_TEMP] + ) + ) if ( entity_features & LightEntityFeature.TRANSITION @@ -702,7 +707,12 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: else: data[STATE_HUE] = HUE_API_STATE_HUE_MIN data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0 + kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN) + data[STATE_COLOR_TEMP] = ( + color_util.color_temperature_kelvin_to_mired(kelvin) + if kelvin is not None + else 0 + ) else: data[STATE_BRIGHTNESS] = 0 diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 3e1bb830cce..fc2855374dd 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,10 +9,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import EnergyZeroDataUpdateCoordinator +from .coordinator import EnergyZeroConfigEntry, EnergyZeroDataUpdateCoordinator from .services import async_setup_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -25,25 +24,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> bool: """Set up EnergyZero from a config entry.""" - coordinator = EnergyZeroDataUpdateCoordinator(hass) + coordinator = EnergyZeroDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: await coordinator.energyzero.close() raise - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> bool: """Unload EnergyZero config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 65955b2ebe6..35054f7b3b7 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -21,6 +21,8 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR +type EnergyZeroConfigEntry = ConfigEntry[EnergyZeroDataUpdateCoordinator] + class EnergyZeroData(NamedTuple): """Class for defining data in dict.""" @@ -35,13 +37,14 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> None: """Initialize global EnergyZero data updater.""" super().__init__( hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + config_entry=entry, ) self.energyzero = EnergyZero(session=async_get_clientsession(hass)) diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index 35d20fee929..0a45d87fee5 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -5,12 +5,9 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import EnergyZeroDataUpdateCoordinator -from .const import DOMAIN -from .coordinator import EnergyZeroData +from .coordinator import EnergyZeroConfigEntry, EnergyZeroData def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: @@ -32,30 +29,31 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: EnergyZeroConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator_data = entry.runtime_data.data + energy_today = coordinator_data.energy_today return { "entry": { "title": entry.title, }, "energy": { - "current_hour_price": coordinator.data.energy_today.current_price, - "next_hour_price": coordinator.data.energy_today.price_at_time( - coordinator.data.energy_today.utcnow() + timedelta(hours=1) + "current_hour_price": energy_today.current_price, + "next_hour_price": energy_today.price_at_time( + energy_today.utcnow() + timedelta(hours=1) ), - "average_price": coordinator.data.energy_today.average_price, - "max_price": coordinator.data.energy_today.extreme_prices[1], - "min_price": coordinator.data.energy_today.extreme_prices[0], - "highest_price_time": coordinator.data.energy_today.highest_price_time, - "lowest_price_time": coordinator.data.energy_today.lowest_price_time, - "percentage_of_max": coordinator.data.energy_today.pct_of_max_price, - "hours_priced_equal_or_lower": coordinator.data.energy_today.hours_priced_equal_or_lower, + "average_price": energy_today.average_price, + "max_price": energy_today.extreme_prices[1], + "min_price": energy_today.extreme_prices[0], + "highest_price_time": energy_today.highest_price_time, + "lowest_price_time": energy_today.lowest_price_time, + "percentage_of_max": energy_today.pct_of_max_price, + "hours_priced_equal_or_lower": energy_today.hours_priced_equal_or_lower, }, "gas": { - "current_hour_price": get_gas_price(coordinator.data, 0), - "next_hour_price": get_gas_price(coordinator.data, 1), + "current_hour_price": get_gas_price(coordinator_data, 0), + "next_hour_price": get_gas_price(coordinator_data, 1), }, } diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index f65f7bd559c..141ac793fba 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CURRENCY_EURO, PERCENTAGE, @@ -27,7 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES -from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator +from .coordinator import ( + EnergyZeroConfigEntry, + EnergyZeroData, + EnergyZeroDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -142,10 +145,12 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EnergyZeroConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EnergyZero Sensors based on a config entry.""" - coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( EnergyZeroSensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index d98699c5c08..c47958b670f 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -10,7 +10,7 @@ from typing import Final from energyzero import Electricity, Gas, VatOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -23,7 +23,7 @@ from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import EnergyZeroDataUpdateCoordinator +from .coordinator import EnergyZeroConfigEntry, EnergyZeroDataUpdateCoordinator ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" @@ -83,12 +83,12 @@ def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: } -def __get_coordinator( - hass: HomeAssistant, call: ServiceCall -) -> EnergyZeroDataUpdateCoordinator: +def __get_coordinator(call: ServiceCall) -> EnergyZeroDataUpdateCoordinator: """Get the coordinator from the entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + entry: EnergyZeroConfigEntry | None = call.hass.config_entries.async_get_entry( + entry_id + ) if not entry: raise ServiceValidationError( @@ -107,17 +107,15 @@ def __get_coordinator( }, ) - coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry_id] - return coordinator + return entry.runtime_data async def __get_prices( call: ServiceCall, *, - hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: - coordinator = __get_coordinator(hass, call) + coordinator = __get_coordinator(call) start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -152,14 +150,14 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.GAS), + partial(__get_prices, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.ENERGY), + partial(__get_prices, price_type=PriceType.ENERGY), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index e9502a0f7cd..b0649a8368d 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -133,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): except Exception: # noqa: BLE001 errors = {"base": "unknown"} else: - await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"]) + unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return errors diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py index a35e74f582f..d5bbf2c0ce5 100644 --- a/homeassistant/components/enigma2/coordinator.py +++ b/homeassistant/components/enigma2/coordinator.py @@ -35,6 +35,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): """The Enigma2 data update coordinator.""" device: OpenWebIfDevice + unique_id: str | None def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the Enigma2 data update coordinator.""" @@ -64,6 +65,10 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): name=config_entry.data[CONF_HOST], ) + # set the unique ID for the entities to the config entry unique ID + # for devices that don't report a MAC address + self.unique_id = config_entry.unique_id + async def _async_setup(self) -> None: """Provide needed data to the device info.""" @@ -71,16 +76,20 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): self.device.mac_address = about["info"]["ifaces"][0]["mac"] self.device_info["model"] = about["info"]["model"] self.device_info["manufacturer"] = about["info"]["brand"] - self.device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, format_mac(iface["mac"])) - for iface in about["info"]["ifaces"] - if "mac" in iface and iface["mac"] is not None - } - self.device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, format_mac(iface["mac"])) - for iface in about["info"]["ifaces"] - if "mac" in iface and iface["mac"] is not None - } + if self.device.mac_address is not None: + self.device_info[ATTR_IDENTIFIERS] = { + (DOMAIN, format_mac(iface["mac"])) + for iface in about["info"]["ifaces"] + if "mac" in iface and iface["mac"] is not None + } + self.device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, format_mac(iface["mac"])) + for iface in about["info"]["ifaces"] + if "mac" in iface and iface["mac"] is not None + } + self.unique_id = self.device.mac_address + elif self.unique_id is not None: + self.device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} async def _async_update_data(self) -> OpenWebIfStatus: await self.device.update() diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 1a0875b04c0..7d6887ad14c 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.2.7"] + "requirements": ["openwebifpy==4.3.0"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 8287e055814..ee0de15c3fb 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -4,7 +4,6 @@ from __future__ import annotations import contextlib from logging import getLogger -from typing import cast from aiohttp.client_exceptions import ServerDisconnectedError from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption @@ -15,7 +14,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -65,10 +63,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti super().__init__(coordinator) - self._attr_unique_id = ( - coordinator.device.mac_address - or cast(ConfigEntry, coordinator.config_entry).entry_id - ) + self._attr_unique_id = coordinator.unique_id self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 6be29d19ecb..1ad6f259de1 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -22,6 +22,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EnvoyEnchargeBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index f27335b1f4c..a62913a4c0b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -25,6 +25,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class EnvoyRelayNumberEntityDescription(NumberEntityDescription): diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 903c2c1edf6..d9729a16683 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class EnvoyRelaySelectEntityDescription(SelectEntityDescription): diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 20d610e4b71..fadbf191840 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -59,6 +59,8 @@ _LOGGER = logging.getLogger(__name__) INVERTERS_KEY = "inverters" LAST_REPORTED_KEY = "last_reported" +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EnvoyInverterSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 14451aaf266..5170b694587 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class EnvoyEnpowerSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 44e5986970d..cedad8b76e2 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -84,7 +84,6 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, ember, zone): """Initialize the thermostat.""" diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 555da1494d7..c3fb0015e68 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -89,7 +89,6 @@ class ControllerEntity(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 1b9b53f24cd..8089fc4712a 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -129,7 +129,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" - _enable_turn_on_off_backwards_compatibility = False @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 454c5edf030..c09145c17b5 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -45,7 +45,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """A fan implementation for ESPHome.""" _supports_speed_levels: bool = True - _enable_turn_on_off_backwards_compatibility = False async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 52f999afe4f..8fecf34862b 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -414,11 +414,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._attr_supported_color_modes = supported self._attr_effect_list = static_info.effects - self._attr_min_mireds = round(static_info.min_mireds) - self._attr_max_mireds = round(static_info.max_mireds) - if ColorMode.COLOR_TEMP in supported: - self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) - self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) + self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) + self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 77a3164d94c..775ffbff4c8 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==27.0.3", + "aioesphomeapi==28.0.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.1.0" ], diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index c1506c00cdc..95ad8a15d1c 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -8,7 +8,7 @@ import lakeside from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -17,10 +17,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.color as color_util -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) EUFYHOME_MAX_KELVIN = 6500 EUFYHOME_MIN_KELVIN = 2700 @@ -41,6 +37,9 @@ def setup_platform( class EufyHomeLight(LightEntity): """Representation of a EufyHome light.""" + _attr_min_color_temp_kelvin = EUFYHOME_MIN_KELVIN + _attr_max_color_temp_kelvin = EUFYHOME_MAX_KELVIN + def __init__(self, device): """Initialize the light.""" @@ -96,23 +95,12 @@ class EufyHomeLight(LightEntity): return int(self._brightness * 255 / 100) @property - def min_mireds(self) -> int: - """Return minimum supported color temperature.""" - return kelvin_to_mired(EUFYHOME_MAX_KELVIN) - - @property - def max_mireds(self) -> int: - """Return maximum supported color temperature.""" - return kelvin_to_mired(EUFYHOME_MIN_KELVIN) - - @property - def color_temp(self): - """Return the color temperature of this light.""" - temp_in_k = int( + def color_temp_kelvin(self) -> int: + """Return the color temperature value in Kelvin.""" + return int( EUFYHOME_MIN_KELVIN + (self._temp * (EUFYHOME_MAX_KELVIN - EUFYHOME_MIN_KELVIN) / 100) ) - return kelvin_to_mired(temp_in_k) @property def hs_color(self): @@ -134,7 +122,7 @@ class EufyHomeLight(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the specified light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - colortemp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hs = kwargs.get(ATTR_HS_COLOR) if brightness is not None: @@ -144,10 +132,9 @@ class EufyHomeLight(LightEntity): self._brightness = 100 brightness = self._brightness - if colortemp is not None: + if color_temp_kelvin is not None: self._colormode = False - temp_in_k = mired_to_kelvin(colortemp) - relative_temp = temp_in_k - EUFYHOME_MIN_KELVIN + relative_temp = color_temp_kelvin - EUFYHOME_MIN_KELVIN temp = int( relative_temp * 100 / (EUFYHOME_MAX_KELVIN - EUFYHOME_MIN_KELVIN) ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 1388585bc17..c71831fa4bc 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -150,7 +150,6 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False class EvoZone(EvoChild, EvoClimateEntity): diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index da3d197f6aa..22edadad7f4 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==0.4.20"] + "requirements": ["evohome-async==0.4.21"] } diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 7c796c74ef7..53976bf3002 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.2.3"] + "requirements": ["pyezviz==0.2.1.2"] } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 71fb9c53353..863ae705603 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from datetime import timedelta from enum import IntFlag import functools as ft @@ -25,7 +24,6 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey @@ -219,99 +217,6 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_speed_count: int = 100 _attr_supported_features: FanEntityFeature = FanEntityFeature(0) - __mod_supported_features: FanEntityFeature = FanEntityFeature(0) - # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False - # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. - _enable_turn_on_off_backwards_compatibility: bool = True - - def __getattribute__(self, name: str, /) -> Any: - """Get attribute. - - Modify return of `supported_features` to - include `_mod_supported_features` if attribute is set. - """ - if name != "supported_features": - return super().__getattribute__(name) - - # Convert the supported features to ClimateEntityFeature. - # Remove this compatibility shim in 2025.1 or later. - _supported_features: FanEntityFeature = super().__getattribute__( - "supported_features" - ) - _mod_supported_features: FanEntityFeature = super().__getattribute__( - "_FanEntity__mod_supported_features" - ) - if type(_supported_features) is int: # noqa: E721 - _features = FanEntityFeature(_supported_features) - self._report_deprecated_supported_features_values(_features) - else: - _features = _supported_features - - if not _mod_supported_features: - return _features - - # Add automatically calculated FanEntityFeature.TURN_OFF/TURN_ON to - # supported features and return it - return _features | _mod_supported_features - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - - def _report_turn_on_off(feature: str, method: str) -> None: - """Log warning not implemented turn on/off feature.""" - report_issue = self._suggest_report_issue() - message = ( - "Entity %s (%s) does not set FanEntityFeature.%s" - " but implements the %s method. Please %s" - ) - _LOGGER.warning( - message, - self.entity_id, - type(self), - feature, - method, - report_issue, - ) - - # Adds FanEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented - # This should be removed in 2025.2. - if self._enable_turn_on_off_backwards_compatibility is False: - # Return if integration has migrated already - return - - supported_features = self.supported_features - if supported_features & (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF): - # The entity supports both turn_on and turn_off, the backwards compatibility - # checks are not needed - return - - if not supported_features & FanEntityFeature.TURN_OFF and ( - type(self).async_turn_off is not ToggleEntity.async_turn_off - or type(self).turn_off is not ToggleEntity.turn_off - ): - # turn_off implicitly supported by implementing turn_off method - _report_turn_on_off("TURN_OFF", "turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - FanEntityFeature.TURN_OFF - ) - - if not supported_features & FanEntityFeature.TURN_ON and ( - type(self).async_turn_on is not FanEntity.async_turn_on - or type(self).turn_on is not FanEntity.turn_on - ): - # turn_on implicitly supported by implementing turn_on method - _report_turn_on_off("TURN_ON", "turn_on") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - FanEntityFeature.TURN_ON - ) - def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" raise NotImplementedError diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index b9f0b006e2a..9faed54c041 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,17 +2,12 @@ from __future__ import annotations -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONF_MAX_ENTRIES, DOMAIN from .coordinator import FeedReaderCoordinator, StoredData type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] @@ -21,60 +16,6 @@ CONF_URLS = "urls" MY_KEY: HassKey[StoredData] = HassKey(DOMAIN) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional( - CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES - ): cv.positive_int, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Feedreader component.""" - if DOMAIN in config: - for url in config[DOMAIN][CONF_URLS]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_URL: url, - CONF_MAX_ENTRIES: config[DOMAIN][CONF_MAX_ENTRIES], - }, - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Feedreader", - }, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool: """Set up Feedreader from a config entry.""" diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 72042de25ed..f3e56ad1778 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -11,7 +11,6 @@ import feedparser import voluptuous as vol from homeassistant.config_entries import ( - SOURCE_IMPORT, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -20,13 +19,11 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, TextSelectorType, ) -from homeassistant.util import slugify from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN @@ -42,7 +39,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 - _max_entries: int | None = None @staticmethod @callback @@ -75,21 +71,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - def abort_on_import_error(self, url: str, error: str) -> ConfigFlowResult: - """Abort import flow on error.""" - async_create_issue( - self.hass, - DOMAIN, - f"import_yaml_error_{DOMAIN}_{error}_{slugify(url)}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"import_yaml_error_{error}", - translation_placeholders={"url": url}, - ) - return self.async_abort(reason=error) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -104,8 +85,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): if feed.bozo: LOGGER.debug("feed bozo_exception: %s", feed.bozo_exception) if isinstance(feed.bozo_exception, urllib.error.URLError): - if self.context["source"] == SOURCE_IMPORT: - return self.abort_on_import_error(user_input[CONF_URL], "url_error") return self.show_user_form(user_input, {"base": "url_error"}) feed_title = html.unescape(feed["feed"]["title"]) @@ -113,14 +92,9 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=feed_title, data=user_input, - options={CONF_MAX_ENTRIES: self._max_entries or DEFAULT_MAX_ENTRIES}, + options={CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES}, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle an import flow.""" - self._max_entries = import_data[CONF_MAX_ENTRIES] - return await self.async_step_user({CONF_URL: import_data[CONF_URL]}) - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 18b9f46eb20..8ede0169482 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -28,8 +28,9 @@ from homeassistant.util import slugify from .const import CONF_IMPORT_PLUGINS, DOMAIN -_LOGGER = logging.getLogger(__name__) +type FibaroConfigEntry = ConfigEntry[FibaroController] +_LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -381,7 +382,7 @@ def init_controller(data: Mapping[str, Any]) -> FibaroController: return controller -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: """Set up the Fibaro Component. The unique id of the config entry is the serial number of the home center. @@ -395,7 +396,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FibaroAuthFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + entry.runtime_data = controller # register the hub device info separately as the hub has sometimes no entities device_registry = dr.async_get(hass) @@ -417,25 +418,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id].disable_state_handler() - hass.data[DOMAIN].pop(entry.entry_id) + entry.runtime_data.disable_state_handler() return unload_ok async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a device entry from fibaro integration. Only removing devices which are not present anymore are eligible to be removed. """ - controller: FibaroController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data for identifiers in controller.get_all_device_identifiers(): if device_entry.identifiers == identifiers: # Fibaro device is still served by the controller, diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 9f3efbfb514..16e79c0c1d0 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -12,13 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity SENSOR_TYPES = { @@ -43,11 +41,11 @@ SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [ FibaroBinarySensor(device) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 0bfc2223317..45f700026a0 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -17,13 +17,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity PRESET_RESUME = "resume" @@ -111,11 +109,11 @@ OP_MODE_ACTIONS = ("setMode", "setOperatingMode", "setThermostatMode") async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [ FibaroThermostat(device) @@ -128,8 +126,6 @@ async def async_setup_entry( class FibaroThermostat(FibaroEntity, ClimateEntity): """Representation of a Fibaro Thermostat.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) @@ -274,7 +270,9 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if isinstance(fibaro_operation_mode, str): with suppress(ValueError): return HVACMode(fibaro_operation_mode.lower()) - elif fibaro_operation_mode in OPMODES_HVAC: + # when the mode cannot be instantiated a preset_mode is selected + return HVACMode.AUTO + if fibaro_operation_mode in OPMODES_HVAC: return OPMODES_HVAC[fibaro_operation_mode] return None @@ -282,8 +280,6 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): """Set new target operation mode.""" if not self._op_mode_device: return - if self.preset_mode: - return if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index 0898d1c9318..bfebbf87bd2 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -13,23 +13,21 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], True, diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index c964ab283c1..a2d5da7f877 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -10,23 +10,21 @@ from homeassistant.components.event import ( EventDeviceClass, EventEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro event entities.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data # Each scene event represents a button on a device async_add_entities( diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 18f86b6df7d..d40e26244f3 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -17,13 +17,11 @@ from homeassistant.components.light import ( brightness_supported, color_supported, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity PARALLEL_UPDATES = 2 @@ -52,11 +50,11 @@ def scaleto99(value: int | None) -> int: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroLight(device) for device in controller.fibaro_devices[Platform.LIGHT]], True, diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 55583d2a967..62a9dfa43b1 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -7,23 +7,21 @@ from typing import Any from pyfibaro.fibaro_device import DeviceModel from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroLock(device) for device in controller.fibaro_devices[Platform.LOCK]], True, diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index a40a1ef5b57..a4c0f1bd7f1 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -7,23 +7,22 @@ from typing import Any from pyfibaro.fibaro_scene import SceneModel from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import FibaroController +from . import FibaroConfigEntry, FibaroController from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro scenes.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroScene(scene, controller) for scene in controller.read_scenes()], True, diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index da94cde9ead..245a0d087d8 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, @@ -27,8 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity # List of known sensors which represents a fibaro device @@ -103,12 +101,12 @@ FIBARO_TO_HASS_UNIT: dict[str, str] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data entities: list[SensorEntity] = [ FibaroSensor(device, MAIN_SENSOR_TYPES.get(device.type)) for device in controller.fibaro_devices[Platform.SENSOR] diff --git a/homeassistant/components/fibaro/strings.json b/homeassistant/components/fibaro/strings.json index de875176cdb..99f718d545c 100644 --- a/homeassistant/components/fibaro/strings.json +++ b/homeassistant/components/fibaro/strings.json @@ -3,16 +3,25 @@ "step": { "user": { "data": { - "url": "URL in the format http://HOST/api/", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "import_plugins": "Import entities from fibaro plugins?" + "import_plugins": "Import entities from fibaro plugins / quickapps" + }, + "data_description": { + "url": "The URL of the Fibaro hub in the format `http(s)://IP`.", + "username": "The username of the Fibaro hub user.", + "password": "The password of the Fibaro hub user.", + "import_plugins": "Select if entities from Fibaro plugins / quickapps should be imported." } }, "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "password": "[%key:component::fibaro::config::step::user::data_description::password%]" + }, "title": "[%key:common::config_flow::title::reauth%]", "description": "Please update your password for {username}" } diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 1ad933f5d20..f67683dff6a 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -7,23 +7,21 @@ from typing import Any from pyfibaro.fibaro_device import DeviceModel from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroSwitch(device) for device in controller.fibaro_devices[Platform.SWITCH]], True, diff --git a/homeassistant/components/fitbit/quality_scale.yaml b/homeassistant/components/fitbit/quality_scale.yaml new file mode 100644 index 00000000000..abf127cdb98 --- /dev/null +++ b/homeassistant/components/fitbit/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration has no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: todo + docs-actions: + status: exempt + comment: There are no actions in Fitbit integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Fitbit is a polling integration that does use async events. + entity-unique-id: done + has-entity-name: done + runtime-data: + status: todo + comment: | + The integration uses `hass.data` for data associated with a configuration + entry and needs to be updated to use `runtime_data`. + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 864160cb464..540a7dd410d 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -71,7 +71,7 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index d456fbef6fc..8be5df4eca7 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -70,7 +70,6 @@ class Flexit(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, hub: ModbusHub, modbus_slave: int | None, name: str | None diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 0526a0d6bd3..a2291dea9d6 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -74,7 +74,6 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: FlexitCoordinator) -> None: """Initialize the Flexit unit.""" diff --git a/homeassistant/components/flexit_bacnet/entity.py b/homeassistant/components/flexit_bacnet/entity.py index bd92550db19..38efa838c93 100644 --- a/homeassistant/components/flexit_bacnet/entity.py +++ b/homeassistant/components/flexit_bacnet/entity.py @@ -26,6 +26,7 @@ class FlexitEntity(CoordinatorEntity[FlexitCoordinator]): name=coordinator.device.device_name, manufacturer="Flexit", model="Nordic", + model_id=coordinator.device.model, serial_number=coordinator.device.serial_number, ) diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 8a3d7ec7260..f7cf5b2c03a 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -43,7 +43,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify from homeassistant.util.color import ( color_RGB_to_xy_brightness, - color_temperature_kelvin_to_mired, color_temperature_to_rgb, ) from homeassistant.util.dt import as_local, utcnow as dt_utcnow @@ -109,13 +108,13 @@ async def async_set_lights_xy(hass, lights, x_val, y_val, brightness, transition await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -async def async_set_lights_temp(hass, lights, mired, brightness, transition): +async def async_set_lights_temp(hass, lights, kelvin, brightness, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): service_data = {ATTR_ENTITY_ID: light} - if mired is not None: - service_data[ATTR_COLOR_TEMP] = int(mired) + if kelvin is not None: + service_data[ATTR_COLOR_TEMP_KELVIN] = kelvin if brightness is not None: service_data[ATTR_BRIGHTNESS] = brightness if transition is not None: @@ -350,17 +349,15 @@ class FluxSwitch(SwitchEntity, RestoreEntity): now, ) else: - # Convert to mired and clamp to allowed values - mired = color_temperature_kelvin_to_mired(temp) await async_set_lights_temp( - self.hass, self._lights, mired, brightness, self._transition + self.hass, self._lights, int(temp), brightness, self._transition ) _LOGGER.debug( ( - "Lights updated to mired:%s brightness:%s, %s%% " + "Lights updated to kelvin:%s brightness:%s, %s%% " "of %s cycle complete at %s" ), - mired, + temp, brightness, round(percentage_complete * 100), time_state, diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index f4982a13c3a..ca7fb7aeea2 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -30,10 +30,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from .const import ( CONF_COLORS, @@ -67,7 +63,7 @@ _LOGGER = logging.getLogger(__name__) MODE_ATTRS = { ATTR_EFFECT, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -205,8 +201,8 @@ class FluxLight( ) -> None: """Initialize the light.""" super().__init__(coordinator, base_unique_id, None) - self._attr_min_mireds = color_temperature_kelvin_to_mired(self._device.max_temp) - self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp) + self._attr_min_color_temp_kelvin = self._device.min_temp + self._attr_max_color_temp_kelvin = self._device.max_temp self._attr_supported_color_modes = _hass_color_modes(self._device) custom_effects: list[str] = [] if custom_effect_colors: @@ -222,9 +218,9 @@ class FluxLight( return self._device.brightness @property - def color_temp(self) -> int: - """Return the kelvin value of this light in mired.""" - return color_temperature_kelvin_to_mired(self._device.color_temp) + def color_temp_kelvin(self) -> int: + """Return the kelvin value of this light.""" + return self._device.color_temp @property def rgb_color(self) -> tuple[int, int, int]: @@ -304,8 +300,7 @@ class FluxLight( await self._async_set_effect(effect, brightness) return # Handle switch to CCT Color Mode - if color_temp_mired := kwargs.get(ATTR_COLOR_TEMP): - color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) + if color_temp_kelvin := kwargs.get(ATTR_COLOR_TEMP_KELVIN): if ( ATTR_BRIGHTNESS not in kwargs and self.color_mode in MULTI_BRIGHTNESS_COLOR_MODES diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index 3aeaa6f7ef2..dd56b3aad72 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -7,6 +7,10 @@ import os from typing import cast from watchdog.events import ( + DirCreatedEvent, + DirDeletedEvent, + DirModifiedEvent, + DirMovedEvent, FileClosedEvent, FileCreatedEvent, FileDeletedEvent, @@ -68,7 +72,7 @@ class EventHandler(PatternMatchingEventHandler): def __init__(self, patterns: list[str], hass: HomeAssistant, entry_id: str) -> None: """Initialise the EventHandler.""" - super().__init__(patterns) + super().__init__(patterns=patterns) self.hass = hass self.entry_id = entry_id @@ -101,19 +105,19 @@ class EventHandler(PatternMatchingEventHandler): signal = f"folder_watcher-{self.entry_id}" dispatcher_send(self.hass, signal, event.event_type, fireable) - def on_modified(self, event: FileModifiedEvent) -> None: + def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None: """File modified.""" self.process(event) - def on_moved(self, event: FileMovedEvent) -> None: + def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None: """File moved.""" self.process(event, moved=True) - def on_created(self, event: FileCreatedEvent) -> None: + def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None: """File created.""" self.process(event) - def on_deleted(self, event: FileDeletedEvent) -> None: + def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None: """File deleted.""" self.process(event) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 7b471e08fcc..1f0d9c595ee 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["watchdog"], "quality_scale": "internal", - "requirements": ["watchdog==2.3.1"] + "requirements": ["watchdog==6.0.0"] } diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index d534db7e858..a5b0144ce0c 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -73,7 +73,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): _attr_current_temperature = 0 _attr_target_temperature = 0 _attr_hvac_mode = HVACMode.OFF - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index 698d57d1001..d21ede9bad3 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -40,7 +40,6 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit _attr_name = None _attr_is_on = False _attr_percentage = 0 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 924d92d6c5b..d5a81fdef1a 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -88,7 +88,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "thermostat" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 2816880a1b2..3c8714624e7 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import suppress +from dataclasses import dataclass from datetime import timedelta import logging import re @@ -19,12 +20,33 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6) +@dataclass +class Contact: + """Store details for one phonebook contact.""" + + name: str + numbers: list[str] + vip: bool + + def __init__( + self, name: str, numbers: list[str] | None = None, category: str | None = None + ) -> None: + """Initialize the class.""" + self.name = name + self.numbers = [re.sub(REGEX_NUMBER, "", nr) for nr in numbers or ()] + self.vip = category == "1" + + +unknown_contact = Contact(UNKNOWN_NAME) + + class FritzBoxPhonebook: """Connects to a FritzBox router and downloads its phone book.""" fph: FritzPhonebook phonebook_dict: dict[str, list[str]] - number_dict: dict[str, str] + contacts: list[Contact] + number_dict: dict[str, Contact] def __init__( self, @@ -56,27 +78,27 @@ class FritzBoxPhonebook: if self.phonebook_id is None: return - self.phonebook_dict = self.fph.get_all_names(self.phonebook_id) - self.number_dict = { - re.sub(REGEX_NUMBER, "", nr): name - for name, nrs in self.phonebook_dict.items() - for nr in nrs - } + self.fph.get_all_name_numbers(self.phonebook_id) + self.contacts = [ + Contact(c.name, c.numbers, getattr(c, "category", None)) + for c in self.fph.phonebook.contacts + ] + self.number_dict = {nr: c for c in self.contacts for nr in c.numbers} _LOGGER.debug("Fritz!Box phone book successfully updated") def get_phonebook_ids(self) -> list[int]: """Return list of phonebook ids.""" return self.fph.phonebook_ids # type: ignore[no-any-return] - def get_name(self, number: str) -> str: - """Return a name for a given phone number.""" + def get_contact(self, number: str) -> Contact: + """Return a contact for a given phone number.""" number = re.sub(REGEX_NUMBER, "", str(number)) with suppress(KeyError): return self.number_dict[number] if not self.prefixes: - return UNKNOWN_NAME + return unknown_contact for prefix in self.prefixes: with suppress(KeyError): @@ -84,4 +106,4 @@ class FritzBoxPhonebook: with suppress(KeyError): return self.number_dict[prefix + number.lstrip("0")] - return UNKNOWN_NAME + return unknown_contact diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 7bd0eacb66a..8435eff3e18 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -12,19 +12,12 @@ from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from homeassistant.config_entries import ( - SOURCE_IMPORT, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from .base import FritzBoxPhonebook @@ -170,16 +163,11 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): if result != ConnectResult.SUCCESS: return self.async_abort(reason=result) - if self.context["source"] == SOURCE_IMPORT: - self._phonebook_id = user_input[CONF_PHONEBOOK] - self._phonebook_name = user_input[CONF_NAME] - - elif len(self._phonebook_ids) > 1: + if len(self._phonebook_ids) > 1: return await self.async_step_phonebook() - else: - self._phonebook_id = DEFAULT_PHONEBOOK - self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id) + self._phonebook_id = DEFAULT_PHONEBOOK + self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id) await self.async_set_unique_id(f"{self._serial_number}-{self._phonebook_id}") self._abort_if_unique_id_configured() diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 668369c35a7..df18ae5702a 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxCallMonitorConfigEntry -from .base import FritzBoxPhonebook +from .base import Contact, FritzBoxPhonebook from .const import ( ATTR_PREFIXES, CONF_PHONEBOOK, @@ -96,7 +96,7 @@ class FritzBoxCallSensor(SensorEntity): self._host = host self._port = port self._monitor: FritzBoxCallMonitor | None = None - self._attributes: dict[str, str | list[str]] = {} + self._attributes: dict[str, str | list[str] | bool] = {} self._attr_translation_placeholders = {"phonebook_name": phonebook_name} self._attr_unique_id = unique_id @@ -152,20 +152,20 @@ class FritzBoxCallSensor(SensorEntity): """Set the state.""" self._attr_native_value = state - def set_attributes(self, attributes: Mapping[str, str]) -> None: + def set_attributes(self, attributes: Mapping[str, str | bool]) -> None: """Set the state attributes.""" self._attributes = {**attributes} @property - def extra_state_attributes(self) -> dict[str, str | list[str]]: + def extra_state_attributes(self) -> dict[str, str | list[str] | bool]: """Return the state attributes.""" if self._prefixes: self._attributes[ATTR_PREFIXES] = self._prefixes return self._attributes - def number_to_name(self, number: str) -> str: - """Return a name for a given phone number.""" - return self._fritzbox_phonebook.get_name(number) + def number_to_contact(self, number: str) -> Contact: + """Return a contact for a given phone number.""" + return self._fritzbox_phonebook.get_contact(number) def update(self) -> None: """Update the phonebook if it is defined.""" @@ -225,35 +225,42 @@ class FritzBoxCallMonitor: df_in = "%d.%m.%y %H:%M:%S" df_out = "%Y-%m-%dT%H:%M:%S" isotime = datetime.strptime(line[0], df_in).strftime(df_out) + att: dict[str, str | bool] if line[1] == FritzState.RING: self._sensor.set_state(CallState.RINGING) + contact = self._sensor.number_to_contact(line[3]) att = { "type": "incoming", "from": line[3], "to": line[4], "device": line[5], "initiated": isotime, - "from_name": self._sensor.number_to_name(line[3]), + "from_name": contact.name, + "vip": contact.vip, } self._sensor.set_attributes(att) elif line[1] == FritzState.CALL: self._sensor.set_state(CallState.DIALING) + contact = self._sensor.number_to_contact(line[5]) att = { "type": "outgoing", "from": line[4], "to": line[5], "device": line[6], "initiated": isotime, - "to_name": self._sensor.number_to_name(line[5]), + "to_name": contact.name, + "vip": contact.vip, } self._sensor.set_attributes(att) elif line[1] == FritzState.CONNECT: self._sensor.set_state(CallState.TALKING) + contact = self._sensor.number_to_contact(line[4]) att = { "with": line[4], "device": line[3], "accepted": isotime, - "with_name": self._sensor.number_to_name(line[4]), + "with_name": contact.name, + "vip": contact.vip, } self._sensor.set_attributes(att) elif line[1] == FritzState.DISCONNECT: diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index e935549035c..437b218a8e2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -78,7 +78,8 @@ "accepted": { "name": "Accepted" }, "with_name": { "name": "With name" }, "duration": { "name": "Duration" }, - "closed": { "name": "Closed" } + "closed": { "name": "Closed" }, + "vip": { "name": "Important" } } } } diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index e30f8e85fa0..4ba893df85c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -60,7 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: FroniusConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True @@ -226,7 +226,14 @@ class FroniusSolarNet: _LOGGER.debug("Re-scan failed for %s", self.host) return inverter_infos - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="entry_cannot_connect", + translation_placeholders={ + "host": self.host, + "fronius_error": str(err), + }, + ) from err for inverter in _inverter_info["inverters"]: solar_net_id = inverter["device_id"]["value"] diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index 2adbf2ae2f3..ccc15d80401 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -52,11 +52,9 @@ async def validate_host( try: inverter_info = await fronius.inverter_info() first_inverter = next(inverter for inverter in inverter_info["inverters"]) - except FroniusError as err: + except (FroniusError, StopIteration) as err: _LOGGER.debug(err) raise CannotConnect from err - except StopIteration as err: - raise CannotConnect("No supported Fronius SolarNet device found.") from err first_inverter_uid: str = first_inverter["unique_id"]["value"] return first_inverter_uid, FroniusConfigEntryData( host=host, @@ -89,7 +87,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured(updates=dict(info)) + self._abort_if_unique_id_configured() return self.async_create_entry(title=create_title(info), data=info) diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 083085270e0..273f1acab41 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -42,8 +42,6 @@ class InverterStatusCodeOption(StrEnum): IDLE = "idle" READY = "ready" SLEEPING = "sleeping" - UNKNOWN = "unknown" - INVALID = "invalid" _INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = { @@ -61,13 +59,13 @@ _INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = { 11: InverterStatusCodeOption.IDLE, 12: InverterStatusCodeOption.READY, 13: InverterStatusCodeOption.SLEEPING, - 255: InverterStatusCodeOption.UNKNOWN, + # 255: "Unknown" is handled by `None` state - same as the invalid codes. } -def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption: +def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption | None: """Return a status message for a given status code.""" - return _INVERTER_STATUS_CODES.get(code, InverterStatusCodeOption.INVALID) # type: ignore[arg-type] + return _INVERTER_STATUS_CODES.get(code) # type: ignore[arg-type] class MeterLocationCodeOption(StrEnum): diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index c3dea123a77..d4f1fc6c230 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + DOMAIN, SOLAR_NET_ID_POWER_FLOW, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo, @@ -67,7 +68,11 @@ class FroniusCoordinatorBase( self._failed_update_count += 1 if self._failed_update_count == self.MAX_FAILED_UPDATES: self.update_interval = self.error_interval - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"fronius_error": str(err)}, + ) from err if self._failed_update_count != 0: self._failed_update_count = 0 diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index c8a840b1c2c..95c5df269e4 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -54,6 +54,9 @@ if TYPE_CHECKING: FroniusStorageUpdateCoordinator, ) + +PARALLEL_UPDATES = 0 + ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh" diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index dfdcfc0ddb2..e2740c76696 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -3,10 +3,12 @@ "flow_title": "{device}", "step": { "user": { - "title": "Fronius SolarNet", - "description": "Configure the IP address or local hostname of your Fronius device.", + "description": "Configure your Fronius SolarAPI device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address or hostname of your Fronius device." } }, "confirm_discovery": { @@ -16,6 +18,9 @@ "description": "Update your configuration information for {device}.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::fronius::config::step::user::data_description::host%]" } } }, @@ -41,9 +46,6 @@ "energy_total": { "name": "Total energy" }, - "frequency_ac": { - "name": "[%key:component::sensor::entity_component::frequency::name%]" - }, "current_ac": { "name": "AC current" }, @@ -84,9 +86,7 @@ "error": "Error", "idle": "Idle", "ready": "Ready", - "sleeping": "Sleeping", - "unknown": "Unknown", - "invalid": "Invalid" + "sleeping": "Sleeping" } }, "led_state": { @@ -156,9 +156,6 @@ "power_apparent_phase_3": { "name": "Apparent power phase 3" }, - "power_apparent": { - "name": "[%key:component::sensor::entity_component::apparent_power::name%]" - }, "power_factor_phase_1": { "name": "Power factor phase 1" }, @@ -168,9 +165,6 @@ "power_factor_phase_3": { "name": "Power factor phase 3" }, - "power_factor": { - "name": "[%key:component::sensor::entity_component::power_factor::name%]" - }, "power_reactive_phase_1": { "name": "Reactive power phase 1" }, @@ -216,12 +210,6 @@ "energy_real_ac_consumed": { "name": "Energy consumed" }, - "power_real_ac": { - "name": "[%key:component::sensor::entity_component::power::name%]" - }, - "temperature_channel_1": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "state_code": { "name": "State code" }, @@ -294,5 +282,13 @@ "name": "[%key:component::sensor::entity_component::temperature::name%]" } } + }, + "exceptions": { + "entry_cannot_connect": { + "message": "Failed to connect to Fronius device at {host}: {fronius_error}" + }, + "update_failed": { + "message": "An error occurred while attempting to fetch data: {fronius_error}" + } } } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 264f0756b82..1f9988dff38 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==20241127.3"] + "requirements": ["home-assistant-frontend==20241127.8"] } diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index 726096eab1a..5359075c728 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -81,8 +81,6 @@ class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility: bool = False - def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: """Store the representation of the device and set the static attributes.""" super().__init__(coordinator, context=device.device_serial_number) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index b29789be87e..1969ebfffe9 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -55,13 +55,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool: """Unload Fyta entity.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: FytaConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py index 18c52d74e25..4c078098ec1 100644 --- a/homeassistant/components/fyta/entity.py +++ b/homeassistant/components/fyta/entity.py @@ -3,10 +3,10 @@ from fyta_cli.fyta_models import Plant from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FytaConfigEntry from .const import DOMAIN from .coordinator import FytaCoordinator @@ -19,7 +19,7 @@ class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]): def __init__( self, coordinator: FytaCoordinator, - entry: ConfigEntry, + entry: FytaConfigEntry, description: SensorEntityDescription, plant_id: int, ) -> None: diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 0df9eca2e38..ea628f55c6c 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -3,9 +3,11 @@ "name": "FYTA", "codeowners": ["@dontinelli"], "config_flow": true, + "dhcp": [{ "hostname": "fyta*" }], "documentation": "https://www.home-assistant.io/integrations/fyta", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["fyta_cli"], + "quality_scale": "platinum", "requirements": ["fyta_cli==0.7.0"] } diff --git a/homeassistant/components/fyta/quality_scale.yaml b/homeassistant/components/fyta/quality_scale.yaml new file mode 100644 index 00000000000..0fbacd0e12e --- /dev/null +++ b/homeassistant/components/fyta/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: No custom action. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No custom action. + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No custom action. + reauthentication-flow: done + parallel-updates: + status: exempt + comment: | + Coordinator and only sensor platform. + + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + No options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: No noisy entities. + discovery: + status: done + comment: DHCP + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: No configuration besides credentials. + dynamic-devices: done + discovery-update-info: + status: exempt + comment: Fyta can be discovered but does not have a local connection. + repair-issues: + status: exempt + comment: | + No issues/repairs. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + No known issues that could be resolved by the user. + docs-examples: + status: exempt + comment: | + As only sensors are provided, no examples deemed necessary/appropriate. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index edd65ad228d..fc9f424d5aa 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -26,7 +26,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index d68eaccbb0c..dd6829eacce 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -63,7 +63,9 @@ from .const import ( CONF_COLD_TOLERANCE, CONF_HEATER, CONF_HOT_TOLERANCE, + CONF_MAX_TEMP, CONF_MIN_DUR, + CONF_MIN_TEMP, CONF_PRESETS, CONF_SENSOR, DEFAULT_TOLERANCE, @@ -77,8 +79,6 @@ DEFAULT_NAME = "Generic Thermostat" CONF_INITIAL_HVAC_MODE = "initial_hvac_mode" CONF_KEEP_ALIVE = "keep_alive" -CONF_MIN_TEMP = "min_temp" -CONF_MAX_TEMP = "max_temp" CONF_PRECISION = "precision" CONF_TARGET_TEMP = "target_temp" CONF_TEMP_STEP = "target_temp_step" @@ -205,7 +205,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Representation of a Generic Thermostat device.""" _attr_should_poll = False - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 5b0eae8ff66..1fbeaefde6b 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -21,7 +21,9 @@ from .const import ( CONF_COLD_TOLERANCE, CONF_HEATER, CONF_HOT_TOLERANCE, + CONF_MAX_TEMP, CONF_MIN_DUR, + CONF_MIN_TEMP, CONF_PRESETS, CONF_SENSOR, DEFAULT_TOLERANCE, @@ -57,6 +59,16 @@ OPTIONS_SCHEMA = { vol.Optional(CONF_MIN_DUR): selector.DurationSelector( selector.DurationSelectorConfig(allow_negative=False) ), + vol.Optional(CONF_MIN_TEMP): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 + ) + ), + vol.Optional(CONF_MAX_TEMP): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 + ) + ), } PRESETS_SCHEMA = { diff --git a/homeassistant/components/generic_thermostat/const.py b/homeassistant/components/generic_thermostat/const.py index 51927297b63..f0e6f1a7d73 100644 --- a/homeassistant/components/generic_thermostat/const.py +++ b/homeassistant/components/generic_thermostat/const.py @@ -18,7 +18,9 @@ CONF_AC_MODE = "ac_mode" CONF_COLD_TOLERANCE = "cold_tolerance" CONF_HEATER = "heater" CONF_HOT_TOLERANCE = "hot_tolerance" +CONF_MAX_TEMP = "max_temp" CONF_MIN_DUR = "min_cycle_duration" +CONF_MIN_TEMP = "min_temp" CONF_PRESETS = { p: f"{p}_temp" for p in ( diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index fd89bec6349..58280e99543 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -12,7 +12,9 @@ "min_cycle_duration": "Minimum cycle duration", "name": "[%key:common::config_flow::data::name%]", "cold_tolerance": "Cold tolerance", - "hot_tolerance": "Hot tolerance" + "hot_tolerance": "Hot tolerance", + "min_temp": "Minimum target temperature", + "max_temp": "Maximum target temperature" }, "data_description": { "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", @@ -45,7 +47,9 @@ "target_sensor": "[%key:component::generic_thermostat::config::step::user::data::target_sensor%]", "min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]", "cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]", - "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]" + "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]", + "min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]", + "max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]" }, "data_description": { "heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]", diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 99d1bde8099..e20d649541e 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -51,7 +51,6 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, broker, zone) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index bedee99f930..07dbd3bd29b 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "quality_scale": "legacy", - "requirements": ["go2rtc-client==0.1.1"], + "quality_scale": "internal", + "requirements": ["go2rtc-client==0.1.2"], "single_config_entry": true } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index f99f1574038..44251a3be04 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -553,15 +553,9 @@ class ColorSettingTrait(_Trait): response["colorModel"] = "hsv" if light.color_temp_supported(color_modes): - # Max Kelvin is Min Mireds K = 1000000 / mireds - # Min Kelvin is Max Mireds K = 1000000 / mireds response["colorTemperatureRange"] = { - "temperatureMaxK": color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MIN_MIREDS) - ), - "temperatureMinK": color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MAX_MIREDS) - ), + "temperatureMaxK": int(attrs.get(light.ATTR_MAX_COLOR_TEMP_KELVIN)), + "temperatureMinK": int(attrs.get(light.ATTR_MIN_COLOR_TEMP_KELVIN)), } return response @@ -583,7 +577,7 @@ class ColorSettingTrait(_Trait): } if light.color_temp_supported([color_mode]): - temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) # Some faulty integrations might put 0 in here, raising exception. if temp == 0: _LOGGER.warning( @@ -592,9 +586,7 @@ class ColorSettingTrait(_Trait): temp, ) elif temp is not None: - color["temperatureK"] = color_util.color_temperature_mired_to_kelvin( - temp - ) + color["temperatureK"] = temp response = {} @@ -606,11 +598,9 @@ class ColorSettingTrait(_Trait): async def execute(self, command, data, params, challenge): """Execute a color temperature command.""" if "temperature" in params["color"]: - temp = color_util.color_temperature_kelvin_to_mired( - params["color"]["temperature"] - ) - min_temp = self.state.attributes[light.ATTR_MIN_MIREDS] - max_temp = self.state.attributes[light.ATTR_MAX_MIREDS] + temp = params["color"]["temperature"] + max_temp = self.state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN] + min_temp = self.state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN] if temp < min_temp or temp > max_temp: raise SmartHomeError( @@ -621,7 +611,10 @@ class ColorSettingTrait(_Trait): await self.hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp}, + { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_COLOR_TEMP_KELVIN: temp, + }, blocking=not self.config.should_report_state, context=data.context, ) @@ -729,7 +722,7 @@ class DockTrait(_Trait): def query_attributes(self) -> dict[str, Any]: """Return dock query attributes.""" - return {"isDocked": self.state.state == vacuum.STATE_DOCKED} + return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED} async def execute(self, command, data, params, challenge): """Execute a dock command.""" @@ -825,8 +818,8 @@ class EnergyStorageTrait(_Trait): "capacityUntilFull": [ {"rawValue": 100 - battery_level, "unit": "PERCENTAGE"} ], - "isCharging": self.state.state == vacuum.STATE_DOCKED, - "isPluggedIn": self.state.state == vacuum.STATE_DOCKED, + "isCharging": self.state.state == vacuum.VacuumActivity.DOCKED, + "isPluggedIn": self.state.state == vacuum.VacuumActivity.DOCKED, } async def execute(self, command, data, params, challenge): @@ -882,8 +875,8 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return { - "isRunning": state == vacuum.STATE_CLEANING, - "isPaused": state == vacuum.STATE_PAUSED, + "isRunning": state == vacuum.VacuumActivity.CLEANING, + "isPaused": state == vacuum.VacuumActivity.PAUSED, } if domain in COVER_VALVE_DOMAINS: diff --git a/homeassistant/components/google_photos/quality_scale.yaml b/homeassistant/components/google_photos/quality_scale.yaml new file mode 100644 index 00000000000..ed313e13d6a --- /dev/null +++ b/homeassistant/components/google_photos/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + config-flow: done + brands: done + dependency-transparency: done + common-modules: done + has-entity-name: + status: exempt + comment: Integration does not have entities + action-setup: + status: todo + comment: | + The integration does action setup in `async_setup_entry` which needs to be + moved to `async_setup`. + appropriate-polling: done + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: done + docs-actions: done + runtime-data: done + + # Silver + log-when-unavailable: todo + config-entry-unloading: todo + reauthentication-flow: done + action-exceptions: todo + docs-installation-parameters: todo + integration-owner: todo + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: todo + entity-unavailable: todo + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: todo + icon-translations: todo + docs-known-limitations: todo + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index bd565a6122d..fa3f4669dac 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -48,7 +48,7 @@ "message": "`{filename}` is not an image" }, "missing_upload_permission": { - "message": "Home Assistnt was not granted permission to upload to Google Photos" + "message": "Home Assistant was not granted permission to upload to Google Photos" }, "upload_error": { "message": "Failed to upload content: {message}" diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 29a1b20f2bc..2ff22068ca9 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from aiohttp import ClientError, ClientResponseError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -12,11 +11,17 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN +from .exceptions import GoogleTasksApiError +from .types import GoogleTasksConfigEntry, GoogleTasksData + +__all__ = [ + "DOMAIN", +] PLATFORMS: list[Platform] = [Platform.TODO] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) -> bool: """Set up Google Tasks from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -36,16 +41,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ClientError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth + try: + task_lists = await auth.list_task_lists() + except GoogleTasksApiError as err: + raise ConfigEntryNotReady from err + + entry.runtime_data = GoogleTasksData(auth, task_lists) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleTasksConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_tasks/quality_scale.yaml b/homeassistant/components/google_tasks/quality_scale.yaml new file mode 100644 index 00000000000..79d216709e5 --- /dev/null +++ b/homeassistant/components/google_tasks/quality_scale.yaml @@ -0,0 +1,69 @@ +rules: + # Bronze + config-flow: done + brands: done + dependency-transparency: todo + common-modules: + status: exempt + comment: | + The integration has a coordinator.py and no base entities. + has-entity-name: done + action-setup: + status: exempt + comment: The integration does not register any actions. + appropriate-polling: done + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: done + docs-actions: + status: exempt + comment: The integration does not register any actions. + runtime-data: done + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: done + action-exceptions: done + docs-installation-parameters: todo + integration-owner: done + parallel-updates: done + test-coverage: done + docs-configuration-parameters: todo + entity-unavailable: done + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: todo + icon-translations: todo + docs-known-limitations: todo + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 5196f89728d..9a44b91b529 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from typing import Any, cast from homeassistant.components.todo import ( @@ -11,16 +11,15 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .api import AsyncConfigEntryAuth -from .const import DOMAIN from .coordinator import TaskUpdateCoordinator +from .types import GoogleTasksConfigEntry +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(minutes=15) TODO_STATUS_MAP = { @@ -39,8 +38,10 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: else: result["status"] = TodoItemStatus.NEEDS_ACTION if (due := item.due) is not None: - # due API field is a timestamp string, but with only date resolution - result["due"] = dt_util.start_of_local_day(due).isoformat() + # due API field is a timestamp string, but with only date resolution. + # The time portion of the date is always discarded by the API, so we + # always set to UTC. + result["due"] = dt_util.start_of_local_day(due).replace(tzinfo=UTC).isoformat() else: result["due"] = None result["notes"] = item.description @@ -51,6 +52,8 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: """Convert tasks API items into a TodoItem.""" due: date | None = None if (due_str := item.get("due")) is not None: + # Due dates are returned always in UTC so we only need to + # parse the date portion which will be interpreted as a a local date. due = datetime.fromisoformat(due_str).date() return TodoItem( summary=item["title"], @@ -65,20 +68,20 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GoogleTasksConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Google Tasks todo platform.""" - api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] - task_lists = await api.list_task_lists() async_add_entities( ( GoogleTaskTodoListEntity( - TaskUpdateCoordinator(hass, api, task_list["id"]), + TaskUpdateCoordinator(hass, entry.runtime_data.api, task_list["id"]), task_list["title"], entry.entry_id, task_list["id"], ) - for task_list in task_lists + for task_list in entry.runtime_data.task_lists ), True, ) diff --git a/homeassistant/components/google_tasks/types.py b/homeassistant/components/google_tasks/types.py new file mode 100644 index 00000000000..eaaec23ddf5 --- /dev/null +++ b/homeassistant/components/google_tasks/types.py @@ -0,0 +1,19 @@ +"""Types for the Google Tasks integration.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.config_entries import ConfigEntry + +from .api import AsyncConfigEntryAuth + + +@dataclass +class GoogleTasksData: + """Class to hold Google Tasks data.""" + + api: AsyncConfigEntryAuth + task_lists: list[dict[str, Any]] + + +type GoogleTasksConfigEntry = ConfigEntry[GoogleTasksData] diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 6a8f48780c8..f197f21a4e1 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -126,7 +126,6 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = TEMP_MIN _attr_max_temp = TEMP_MAX - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 03341b0f46b..87d9cb281f4 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -109,7 +109,6 @@ class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" _attr_available: bool = False - _enable_turn_on_off_backwards_compatibility = False def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 7ac5770f171..2f3c4aa5221 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -11,7 +11,7 @@ from typing import Protocol from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.components.climate import HVACMode from homeassistant.components.lock import LockState -from homeassistant.components.vacuum import STATE_CLEANING, STATE_ERROR, STATE_RETURNING +from homeassistant.components.vacuum import VacuumActivity from homeassistant.components.water_heater import ( STATE_ECO, STATE_ELECTRIC, @@ -105,9 +105,9 @@ ON_OFF_STATES: dict[Platform | str, tuple[set[str], str, str]] = { Platform.VACUUM: ( { STATE_ON, - STATE_CLEANING, - STATE_RETURNING, - STATE_ERROR, + VacuumActivity.CLEANING, + VacuumActivity.RETURNING, + VacuumActivity.ERROR, }, STATE_ON, STATE_OFF, diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml new file mode 100644 index 00000000000..9d505b85b8c --- /dev/null +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: test already_configured, tests should finish with create_entry or abort, assert unique_id + config-flow: done + dependency-transparency: todo + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No events are registered by the integration. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There is no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration represents a service + discovery: + status: exempt + comment: Integration represents a service + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No supportable devices. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Integration is a service, no devices that could be added at runtime. + Button entities for casting skills are created/removed dynamically if unlocked or on class change + entity-category: + status: done + comment: Default categories are appropriate for currently available entities. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + comment: translations for UpdateFailed missing + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: done + comment: Used to inform of deprecated entities and actions. + stale-devices: + status: done + comment: Not applicable. Only one device per config entry. Removed together with the config entry. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py new file mode 100644 index 00000000000..0353255fe7b --- /dev/null +++ b/homeassistant/components/hassio/backup.py @@ -0,0 +1,402 @@ +"""Backup functionality for supervised installations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +import logging +from pathlib import Path +from typing import Any, cast + +from aiohasupervisor.exceptions import SupervisorBadRequestError +from aiohasupervisor.models import ( + backups as supervisor_backups, + mounts as supervisor_mounts, +) + +from homeassistant.components.backup import ( + DATA_MANAGER, + AddonInfo, + AgentBackup, + BackupAgent, + BackupReaderWriter, + CreateBackupEvent, + Folder, + NewBackup, + WrittenBackup, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN, EVENT_SUPERVISOR_EVENT +from .handler import get_supervisor_client + +LOCATION_CLOUD_BACKUP = ".cloud_backup" +MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +_LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the hassio backup agents.""" + client = get_supervisor_client(hass) + mounts = await client.mounts.info() + agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] + for mount in mounts.mounts: + if mount.usage is not supervisor_mounts.MountUsage.BACKUP: + continue + agents.append(SupervisorBackupAgent(hass, mount.name, mount.name)) + return agents + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + + @callback + def unsub() -> None: + """Unsubscribe from job events.""" + unsub_signal() + + @callback + def handle_signal(data: Mapping[str, Any]) -> None: + """Handle a job signal.""" + if ( + data.get("event") != "job" + or not (event_data := data.get("data")) + or event_data.get("name") not in MOUNT_JOBS + or event_data.get("done") is not True + ): + return + _LOGGER.debug("Mount added or removed %s, calling listener", data) + listener() + + unsub_signal = async_dispatcher_connect(hass, EVENT_SUPERVISOR_EVENT, handle_signal) + return unsub + + +def _backup_details_to_agent_backup( + details: supervisor_backups.BackupComplete, +) -> AgentBackup: + """Convert a supervisor backup details object to an agent backup.""" + homeassistant_included = details.homeassistant is not None + if not homeassistant_included: + database_included = False + else: + database_included = details.homeassistant_exclude_database is False + addons = [ + AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) + for addon in details.addons + ] + return AgentBackup( + addons=addons, + backup_id=details.slug, + database_included=database_included, + date=details.date.isoformat(), + folders=[Folder(folder) for folder in details.folders], + homeassistant_included=homeassistant_included, + homeassistant_version=details.homeassistant, + name=details.name, + protected=details.protected, + size=details.size_bytes, + ) + + +class SupervisorBackupAgent(BackupAgent): + """Backup agent for supervised installations.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._backup_dir = Path("/backups") + self._client = get_supervisor_client(hass) + self.name = name + self.location = location + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + return await self._client.backups.download_backup(backup_id) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + Not required for supervisor, the SupervisorBackupReaderWriter stores files. + """ + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backup_list = await self._client.backups.list() + result = [] + for backup in backup_list: + if not backup.locations or self.location not in backup.locations: + continue + details = await self._client.backups.backup_info(backup.slug) + result.append(_backup_details_to_agent_backup(details)) + return result + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + details = await self._client.backups.backup_info(backup_id) + if self.location not in details.locations: + return None + return _backup_details_to_agent_backup(details) + + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Remove a backup.""" + try: + await self._client.backups.remove_backup(backup_id) + except SupervisorBadRequestError as err: + if err.args[0] != "Backup does not exist": + raise + + +class SupervisorBackupReaderWriter(BackupReaderWriter): + """Class for reading and writing backups in supervised installations.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup reader/writer.""" + self._hass = hass + self._client = get_supervisor_client(hass) + + async def async_create_backup( + self, + *, + agent_ids: list[str], + backup_name: str, + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + on_progress: Callable[[CreateBackupEvent], None], + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: + """Create a backup.""" + manager = self._hass.data[DATA_MANAGER] + + include_addons_set = set(include_addons) if include_addons else None + include_folders_set = ( + {supervisor_backups.Folder(folder) for folder in include_folders} + if include_folders + else None + ) + + hassio_agents: list[SupervisorBackupAgent] = [ + cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) + for agent_id in agent_ids + if manager.backup_agents[agent_id].domain == DOMAIN + ] + locations = {agent.location for agent in hassio_agents} + + backup = await self._client.backups.partial_backup( + supervisor_backups.PartialBackupOptions( + addons=include_addons_set, + folders=include_folders_set, + homeassistant=include_homeassistant, + name=backup_name, + password=password, + compressed=True, + location=locations or LOCATION_CLOUD_BACKUP, + homeassistant_exclude_database=not include_database, + background=True, + ) + ) + backup_task = self._hass.async_create_task( + self._async_wait_for_backup( + backup, remove_after_upload=not bool(locations) + ), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return + ) + + return (NewBackup(backup_job_id=backup.job_id), backup_task) + + async def _async_wait_for_backup( + self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + ) -> WrittenBackup: + """Wait for a backup to complete.""" + backup_complete = asyncio.Event() + backup_id: str | None = None + + @callback + def on_progress(data: Mapping[str, Any]) -> None: + """Handle backup progress.""" + nonlocal backup_id + if data.get("done") is True: + backup_id = data.get("reference") + backup_complete.set() + + try: + unsub = self._async_listen_job_events(backup.job_id, on_progress) + await backup_complete.wait() + finally: + unsub() + if not backup_id: + raise HomeAssistantError("Backup failed") + + async def open_backup() -> AsyncIterator[bytes]: + return await self._client.backups.download_backup(backup_id) + + async def remove_backup() -> None: + if not remove_after_upload: + return + await self._client.backups.remove_backup(backup_id) + + details = await self._client.backups.backup_info(backup_id) + + return WrittenBackup( + backup=_backup_details_to_agent_backup(details), + open_stream=open_backup, + release_stream=remove_backup, + ) + + async def async_receive_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + suggested_filename: str, + ) -> WrittenBackup: + """Receive a backup.""" + manager = self._hass.data[DATA_MANAGER] + + hassio_agents: list[SupervisorBackupAgent] = [ + cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) + for agent_id in agent_ids + if manager.backup_agents[agent_id].domain == DOMAIN + ] + locations = {agent.location for agent in hassio_agents} + + backup_id = await self._client.backups.upload_backup( + stream, + supervisor_backups.UploadBackupOptions( + location=locations or {LOCATION_CLOUD_BACKUP} + ), + ) + + async def open_backup() -> AsyncIterator[bytes]: + return await self._client.backups.download_backup(backup_id) + + async def remove_backup() -> None: + if locations: + return + await self._client.backups.remove_backup(backup_id) + + details = await self._client.backups.backup_info(backup_id) + + return WrittenBackup( + backup=_backup_details_to_agent_backup(details), + open_stream=open_backup, + release_stream=remove_backup, + ) + + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Restore a backup.""" + if restore_homeassistant and not restore_database: + raise HomeAssistantError("Cannot restore Home Assistant without database") + if not restore_homeassistant and restore_database: + raise HomeAssistantError("Cannot restore database without Home Assistant") + restore_addons_set = set(restore_addons) if restore_addons else None + restore_folders_set = ( + {supervisor_backups.Folder(folder) for folder in restore_folders} + if restore_folders + else None + ) + + manager = self._hass.data[DATA_MANAGER] + if manager.backup_agents[agent_id].domain != DOMAIN: + # Download the backup to the supervisor. Supervisor will clean up the backup + # two days after the restore is done. + await self.async_receive_backup( + agent_ids=[], + stream=await open_stream(), + suggested_filename=f"{backup_id}.tar", + ) + + job = await self._client.backups.partial_restore( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=restore_addons_set, + folders=restore_folders_set, + homeassistant=restore_homeassistant, + password=password, + background=True, + ), + ) + + restore_complete = asyncio.Event() + + @callback + def on_progress(data: Mapping[str, Any]) -> None: + """Handle backup progress.""" + if data.get("done") is True: + restore_complete.set() + + try: + unsub = self._async_listen_job_events(job.job_id, on_progress) + await restore_complete.wait() + finally: + unsub() + + @callback + def _async_listen_job_events( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> Callable[[], None]: + """Listen for job events.""" + + @callback + def unsub() -> None: + """Unsubscribe from job events.""" + unsub_signal() + + @callback + def handle_signal(data: Mapping[str, Any]) -> None: + """Handle a job signal.""" + if ( + data.get("event") != "job" + or not (event_data := data.get("data")) + or event_data.get("uuid") != job_id + ): + return + on_event(event_data) + + unsub_signal = async_dispatcher_connect( + self._hass, EVENT_SUPERVISOR_EVENT, handle_signal + ) + return unsub diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 31fa27a92c4..8fe124e763c 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.1"], + "requirements": ["aiohasupervisor==0.2.2b0"], "single_config_entry": true } diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index 1102dbc0c74..de66315a467 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -82,7 +82,6 @@ class HeatmiserV3Thermostat(ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, therm, device, uh1): """Initialize the thermostat.""" diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index de56e541501..e6a46f5a4ca 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -8,23 +8,19 @@ from datetime import timedelta import logging from pyheos import Heos, HeosError, HeosPlayer, const as heos_const -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import services -from .config_flow import format_title from .const import ( COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, @@ -35,14 +31,6 @@ from .const import ( PLATFORMS = [Platform.MEDIA_PLAYER] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) - MIN_UPDATE_SOURCES = timedelta(seconds=1) _LOGGER = logging.getLogger(__name__) @@ -61,30 +49,6 @@ class HeosRuntimeData: type HeosConfigEntry = ConfigEntry[HeosRuntimeData] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the HEOS component.""" - if DOMAIN not in config: - return True - host = config[DOMAIN][CONF_HOST] - entries = hass.config_entries.async_entries(DOMAIN) - if not entries: - # Create new entry based on config - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host} - ) - ) - else: - # Check if host needs to be updated - entry = entries[0] - if entry.data[CONF_HOST] != host: - hass.config_entries.async_update_entry( - entry, title=format_title(host), data={**entry.data, CONF_HOST: host} - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Initialize config entry which represents the HEOS controller.""" # For backwards compat diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 57ed51a3c05..f861247d1a9 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -10,12 +10,25 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from .const import DATA_DISCOVERED_HOSTS, DOMAIN +from .const import DOMAIN def format_title(host: str) -> str: """Format the title for config entries.""" - return f"Controller ({host})" + return f"HEOS System (via {host})" + + +async def _validate_host(host: str, errors: dict[str, str]) -> bool: + """Validate host is reachable, return True, otherwise populate errors and return False.""" + heos = Heos(host) + try: + await heos.connect() + except HeosError: + errors[CONF_HOST] = "cannot_connect" + return False + finally: + await heos.disconnect() + return True class HeosFlowHandler(ConfigFlow, domain=DOMAIN): @@ -34,56 +47,56 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): friendly_name = ( f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" ) - self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) - self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname - # Abort if other flows in progress or an entry already exists - if self._async_in_progress() or self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + self.hass.data.setdefault(DOMAIN, {}) + self.hass.data[DOMAIN][friendly_name] = hostname await self.async_set_unique_id(DOMAIN) # Show selection form return self.async_show_form(step_id="user") - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Occurs when an entry is setup through config.""" - host = import_data[CONF_HOST] - # raise_on_progress is False here in case ssdp discovers - # heos first which would block the import - await self.async_set_unique_id(DOMAIN, raise_on_progress=False) - return self.async_create_entry(title=format_title(host), data={CONF_HOST: host}) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) - # Only a single entry is needed for all devices - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + self.hass.data.setdefault(DOMAIN, {}) + await self.async_set_unique_id(DOMAIN) # Try connecting to host if provided - errors = {} + errors: dict[str, str] = {} host = None if user_input is not None: host = user_input[CONF_HOST] # Map host from friendly name if in discovered hosts - host = self.hass.data[DATA_DISCOVERED_HOSTS].get(host, host) - heos = Heos(host) - try: - await heos.connect() - self.hass.data.pop(DATA_DISCOVERED_HOSTS) - return await self.async_step_import({CONF_HOST: host}) - except HeosError: - errors[CONF_HOST] = "cannot_connect" - finally: - await heos.disconnect() + host = self.hass.data[DOMAIN].get(host, host) + if await _validate_host(host, errors): + self.hass.data.pop(DOMAIN) # Remove discovery data + return self.async_create_entry( + title=format_title(host), data={CONF_HOST: host} + ) # Return form host_type = ( - str - if not self.hass.data[DATA_DISCOVERED_HOSTS] - else vol.In(list(self.hass.data[DATA_DISCOVERED_HOSTS])) + str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN])) ) return self.async_show_form( step_id="user", data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow reconfiguration of entry.""" + entry = self._get_reconfigure_entry() + host = entry.data[CONF_HOST] # Get current host value + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + if await _validate_host(host, errors): + return self.async_update_reload_and_abort( + entry, data_updates={CONF_HOST: host} + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors=errors, + ) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 827a0c53fbf..5b2df2b5ebf 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -4,7 +4,6 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" COMMAND_RETRY_ATTEMPTS = 2 COMMAND_RETRY_DELAY = 1 -DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index a90f0aebaae..12f10bcd0e3 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -7,6 +7,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "requirements": ["pyheos==0.7.2"], + "single_config_entry": true, "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml new file mode 100644 index 00000000000..39c25486e52 --- /dev/null +++ b/homeassistant/components/heos/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: + status: todo + comment: Future enhancement to move custom actions for login/out into an options flow. + appropriate-polling: + status: done + comment: Integration is a local push integration + brands: done + common-modules: todo + config-flow-test-coverage: done + config-flow: + status: done + comment: Consider enhnacement to automatically select a host when multiple are discovered. + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: todo + comment: | + Simplify by using async_on_remove instead of keeping track of listeners to remove + later in async_will_remove_from_hass. + 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: Actions currently only log and instead should raise exceptions. + config-entry-unloading: done + docs-configuration-parameters: + status: done + comment: | + The integration doesn't provide any additional configuration parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: todo + comment: | + The integration currently spams the logs until reconnected + parallel-updates: + status: todo + comment: Needs to be set to 0. The underlying library handles parallel updates. + reauthentication-flow: + status: exempt + comment: | + This integration doesn't require re-authentication. + test-coverage: + status: todo + comment: | + 1. Integration has >95% coverage, however tests need to be updated to not patch internals. + 2. test_async_setup_entry_connect_failure and test_async_setup_entry_player_failure -> Instead of + calling async_setup_entry directly, rather use hass.config_entries.async_setup and then assert + the config_entry.state is what we expect. + 3. test_unload_entry -> We should use hass.config_entries.async_unload and assert the entry state + 4. Recommend using snapshot in test_state_attributes. + 5. Find a way to avoid using internal dispatcher in test_updates_from_connection_event. + # Gold + devices: + status: todo + comment: | + The integraiton creates devices, but needs to stringify the id for the device identifier and + also migrate the device. + diagnostics: todo + discovery-update-info: + status: todo + comment: Explore if this is possible. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: + status: todo + comment: Has some troublehsooting setps, but needs to be improved + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: + status: done + comment: The integration does not use websession + strict-typing: todo diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index df18fc7834a..fe4fc63b449 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -2,13 +2,23 @@ "config": { "step": { "user": { - "title": "Connect to Heos", - "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", + "title": "Connect to HEOS", + "description": "Please enter the host name or IP address of a HEOS-capable product to access your HEOS System.", "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your HEOS device." + "host": "Host name or IP address of a HEOS-capable product (preferrably one connected via wire to the network)." + } + }, + "reconfigure": { + "title": "Reconfigure HEOS", + "description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::heos::config::step::user::data_description::host%]" } } }, @@ -16,13 +26,15 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "services": { "sign_in": { "name": "Sign in", - "description": "Signs the controller in to a HEOS account.", + "description": "Signs in to a HEOS account.", "fields": { "username": { "name": "[%key:common::config_flow::data::username%]", @@ -36,7 +48,7 @@ }, "sign_out": { "name": "Sign out", - "description": "Signs the controller out of the HEOS account." + "description": "Signs out of the HEOS account." } } } diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 656ba6c68c0..68f79439162 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -155,7 +155,6 @@ class ClimateAehW4a1(ClimateEntity): _attr_target_temperature_step = 1 _previous_state: HVACMode | str | None = None _on: str | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Initialize the climate device.""" diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7241e1fac9a..365be06fd2d 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -22,7 +22,7 @@ import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN -from .helpers import entities_may_have_state_changes_after, has_states_before +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after CONF_ORDER = "use_include_order" @@ -107,10 +107,7 @@ class HistoryPeriodView(HomeAssistantView): no_attributes = "no_attributes" in request.query if ( - # has_states_before will return True if there are states older than - # end_time. If it's false, we know there are no states in the - # database up until end_time. - (end_time and not has_states_before(hass, end_time)) + (end_time and not has_recorder_run_after(hass, end_time)) or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index 2010b7373ff..bd477e7e4ed 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -6,6 +6,7 @@ from collections.abc import Iterable from datetime import datetime as dt from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -25,10 +26,8 @@ def entities_may_have_state_changes_after( return False -def has_states_before(hass: HomeAssistant, run_time: dt) -> bool: - """Check if the recorder has states as old or older than run_time. - - Returns True if there may be such states. - """ - oldest_ts = get_instance(hass).states_manager.oldest_ts - return oldest_ts is not None and run_time.timestamp() >= oldest_ts +def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has any runs after a specific time.""" + return run_time >= process_timestamp( + get_instance(hass).recorder_runs_manager.first.start + ) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 35f8ed5f1ac..c85d975c3c9 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ from homeassistant.util.async_ import create_eager_task import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES -from .helpers import entities_may_have_state_changes_after, has_states_before +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after _LOGGER = logging.getLogger(__name__) @@ -142,10 +142,7 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - # has_states_before will return True if there are states older than - # end_time. If it's false, we know there are no states in the - # database up until end_time. - (end_time and not has_states_before(hass, end_time)) + (end_time and not has_recorder_run_after(hass, end_time)) or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 1c11ccad595..ac008b857af 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -10,65 +10,24 @@ from typing import Any, Concatenate from aiohttp.web_exceptions import HTTPException from apyhiveapi import Auth, Hive from apyhiveapi.helper.hive_exceptions import HiveReauthRequired -import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS from .entity import HiveEntity _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=2): cv.positive_int, - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Hive configuration setup.""" - hass.data[DOMAIN] = {} - - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_USERNAME: conf[CONF_USERNAME], - CONF_PASSWORD: conf[CONF_PASSWORD], - }, - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hive from a config entry.""" + hass.data.setdefault(DOMAIN, {}) web_session = aiohttp_client.async_get_clientsession(hass) hive_config = dict(entry.data) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 4e5ea95f2fa..c76379cf940 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -100,7 +100,6 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: """Initialize the Climate device.""" diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index a997954f4cc..e3180dc9734 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -104,7 +104,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "no_internet_available" if not errors: - if self.context["source"] == SOURCE_REAUTH: + if self.source == SOURCE_REAUTH: return await self.async_setup_hive_entry() self.device_registration = True return await self.async_step_configuration() @@ -144,7 +144,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): # Setup the config entry self.data["tokens"] = self.tokens - if self.context["source"] == SOURCE_REAUTH: + if self.source == SOURCE_REAUTH: assert self.entry self.hass.config_entries.async_update_entry( self.entry, title=self.data["username"], data=self.data @@ -163,10 +163,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): } return await self.async_step_user(data) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import user.""" - return await self.async_step_user(import_data) - @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 10de781bf1d..b510569eb47 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -43,6 +43,9 @@ async def async_setup_entry( class HiveDeviceLight(HiveEntity, LightEntity): """Hive Active Light Device.""" + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds + def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: """Initialise hive light.""" super().__init__(hive, hive_device) @@ -56,9 +59,6 @@ class HiveDeviceLight(HiveEntity, LightEntity): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} self._attr_color_mode = ColorMode.UNKNOWN - self._attr_min_mireds = 153 - self._attr_max_mireds = 370 - @refresh_system async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" @@ -71,9 +71,8 @@ class HiveDeviceLight(HiveEntity, LightEntity): new_brightness = int(round(percentage_brightness / 5.0) * 5.0) if new_brightness == 0: new_brightness = 5 - if ATTR_COLOR_TEMP in kwargs: - tmp_new_color_temp = kwargs[ATTR_COLOR_TEMP] - new_color_temp = round(1000000 / tmp_new_color_temp) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + new_color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] if ATTR_HS_COLOR in kwargs: get_new_color = kwargs[ATTR_HS_COLOR] hue = int(get_new_color[0]) @@ -102,12 +101,22 @@ class HiveDeviceLight(HiveEntity, LightEntity): self._attr_is_on = self.device["status"]["state"] self._attr_brightness = self.device["status"]["brightness"] if self.device["hiveType"] == "tuneablelight": - self._attr_color_temp = self.device["status"].get("color_temp") + color_temp = self.device["status"].get("color_temp") + self._attr_color_temp_kelvin = ( + None + if color_temp is None + else color_util.color_temperature_mired_to_kelvin(color_temp) + ) + if self.device["hiveType"] == "colourtuneablelight": if self.device["status"]["mode"] == "COLOUR": rgb = self.device["status"]["hs_color"] self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) self._attr_color_mode = ColorMode.HS else: - self._attr_color_temp = self.device["status"].get("color_temp") + self._attr_color_temp_kelvin = ( + None + if color_temp is None + else color_util.color_temperature_mired_to_kelvin(color_temp) + ) self._attr_color_mode = ColorMode.COLOR_TEMP diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index a3c0a4514d3..7edc140da11 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.61", "babel==2.15.0"] + "requirements": ["holidays==0.62", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 6e89fd2c9f7..818c4e6fe19 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, @@ -39,6 +40,9 @@ from .const import ( SERVICE_SELECT_PROGRAM, SERVICE_SETTING, SERVICE_START_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_KEY, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_VALUE, ) type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] @@ -139,6 +143,43 @@ def _get_appliance( raise ValueError(f"Appliance for device id {device_entry.id} not found") +def _get_appliance_or_raise_service_validation_error( + hass: HomeAssistant, device_id: str +) -> api.HomeConnectAppliance: + """Return a Home Connect appliance instance or raise a service validation error.""" + try: + return _get_appliance(hass, device_id) + except (ValueError, AssertionError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) from err + + +async def _run_appliance_service[*_Ts]( + hass: HomeAssistant, + appliance: api.HomeConnectAppliance, + method: str, + *args: *_Ts, + error_translation_key: str, + error_translation_placeholders: dict[str, str], +) -> None: + try: + await hass.async_add_executor_job(getattr(appliance, method), args) + except api.HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **error_translation_placeholders, + }, + ) from err + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" @@ -158,16 +199,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: option[ATTR_UNIT] = option_unit options.append(option) - - appliance = _get_appliance(hass, device_id) - await hass.async_add_executor_job(getattr(appliance, method), program, options) + await _run_appliance_service( + hass, + _get_appliance_or_raise_service_validation_error(hass, device_id), + method, + program, + options, + error_translation_key=method, + error_translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) async def _async_service_command(call, command): """Execute calls to services executing a command.""" device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance(hass, device_id) - await hass.async_add_executor_job(appliance.execute_command, command) + appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) + await _run_appliance_service( + hass, + appliance, + "execute_command", + command, + error_translation_key="execute_command", + error_translation_placeholders={"command": command}, + ) async def _async_service_key_value(call, method): """Execute calls to services taking a key and value.""" @@ -176,20 +232,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: unit = call.data.get(ATTR_UNIT) device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance(hass, device_id) - if unit is not None: - await hass.async_add_executor_job( - getattr(appliance, method), - key, - value, - unit, - ) - else: - await hass.async_add_executor_job( - getattr(appliance, method), - key, - value, - ) + await _run_appliance_service( + hass, + _get_appliance_or_raise_service_validation_error(hass, device_id), + method, + *((key, value) if unit is None else (key, value, unit)), + error_translation_key=method, + error_translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) async def async_service_option_active(call): """Service for setting an option for an active program.""" diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e9f32b0e772..e20cf3b1fa0 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -127,9 +127,12 @@ ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" -SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY = "setting_key" +SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" +SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index fc53939b9d8..0703b4772bb 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -22,8 +22,9 @@ from .const import ( ATTR_UNIT, ATTR_VALUE, DOMAIN, + SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectEntity @@ -119,11 +120,11 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_setting", + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), }, ) from err diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 46b2bda24d6..c97b3db28e0 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -22,6 +22,7 @@ from .const import ( BSH_ACTIVE_PROGRAM, BSH_SELECTED_PROGRAM, DOMAIN, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, ) from .entity import HomeConnectEntity @@ -294,7 +295,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, }, ) from err self.async_entity_update() diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5f5ed3cee54..e70f2f28c65 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -22,6 +22,9 @@ } }, "exceptions": { + "appliance_not_found": { + "message": "Appliance for device id {device_id} not found" + }, "turn_on_light": { "message": "Error turning on {entity_id}: {description}" }, @@ -37,14 +40,17 @@ "set_light_color": { "message": "Error setting color of {entity_id}: {description}" }, + "set_setting_entity": { + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{setting_key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" }, "turn_on": { - "message": "Error turning on {entity_id} ({setting_key}): {description}" + "message": "Error turning on {entity_id} ({key}): {description}" }, "turn_off": { - "message": "Error turning off {entity_id} ({setting_key}): {description}" + "message": "Error turning off {entity_id} ({key}): {description}" }, "select_program": { "message": "Error selecting program {program}: {description}" @@ -52,8 +58,20 @@ "start_program": { "message": "Error starting program {program}: {description}" }, + "pause_program": { + "message": "Error pausing program: {description}" + }, "stop_program": { - "message": "Error stopping program {program}: {description}" + "message": "Error stopping program: {description}" + }, + "set_options_active_program": { + "message": "Error setting options for the active program: {description}" + }, + "set_options_selected_program": { + "message": "Error setting options for the selected program: {description}" + }, + "execute_command": { + "message": "Error executing command {command}: {description}" }, "power_on": { "message": "Error turning on {appliance_name}: {description}" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 7e3a285912b..acb78e87db1 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -30,7 +30,7 @@ from .const import ( REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectDevice, HomeConnectEntity @@ -140,7 +140,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, }, ) from err @@ -164,7 +164,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, }, ) from err @@ -230,7 +230,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="stop_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, }, ) from err self.async_entity_update() diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index cad16d63cb2..c1f125cd2f7 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -14,8 +14,9 @@ from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( ATTR_VALUE, DOMAIN, + SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectEntity @@ -82,11 +83,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_setting", + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), }, ) from err diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 52b330bfbc8..3283d480fdd 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -10,6 +10,10 @@ "title": "The country has not been configured", "description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below." }, + "imperial_unit_system": { + "title": "The imperial unit system is deprecated", + "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue." + }, "deprecated_yaml": { "title": "The {integration_title} YAML configuration is being removed", "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 68df6c38ad6..0482a5956ac 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -21,7 +21,7 @@ from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, - STATE_CLEANING, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -213,7 +213,7 @@ class Vacuum(Switch): @callback def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" - current_state = new_state.state in (STATE_CLEANING, STATE_ON) + current_state = new_state.state in (VacuumActivity.CLEANING, STATE_ON) _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) self.char_on.set_value(current_state) diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 3cb80f2c817..b17f122dfa5 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -69,6 +69,7 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 4e55c8212be..ba5237e6e2d 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -136,7 +136,6 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): """The base HomeKit Controller climate entity.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False @callback def _async_reconfigure(self) -> None: diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 63de146a024..2ae534099ae 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -42,7 +42,6 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # This must be set in subclasses to the name of a boolean characteristic # that controls whether the fan is on or off. on_characteristic: str - _enable_turn_on_off_backwards_compatibility = False @callback def _async_reconfigure(self) -> None: diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 472ccfbd550..26f10768aa0 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -10,8 +10,10 @@ from propcache import cached_property from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, ) @@ -53,11 +55,19 @@ async def async_setup_entry( class HomeKitLight(HomeKitEntity, LightEntity): """Representation of a Homekit light.""" + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + @callback def _async_reconfigure(self) -> None: """Reconfigure entity.""" self._async_clear_property_cache( - ("supported_features", "min_mireds", "max_mireds", "supported_color_modes") + ( + "supported_features", + "min_color_temp_kelvin", + "max_color_temp_kelvin", + "supported_color_modes", + ) ) super()._async_reconfigure() @@ -90,25 +100,35 @@ class HomeKitLight(HomeKitEntity, LightEntity): ) @cached_property - def min_mireds(self) -> int: - """Return minimum supported color temperature.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): - return super().min_mireds - min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue - return int(min_value) if min_value else super().min_mireds + return DEFAULT_MAX_KELVIN + min_value_mireds = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue + return ( + color_util.color_temperature_mired_to_kelvin(min_value_mireds) + if min_value_mireds + else DEFAULT_MAX_KELVIN + ) @cached_property - def max_mireds(self) -> int: - """Return the maximum color temperature.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): - return super().max_mireds - max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue - return int(max_value) if max_value else super().max_mireds + return DEFAULT_MIN_KELVIN + max_value_mireds = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue + return ( + color_util.color_temperature_mired_to_kelvin(max_value_mireds) + if max_value_mireds + else DEFAULT_MIN_KELVIN + ) @property - def color_temp(self) -> int: - """Return the color temperature.""" - return self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE) + def color_temp_kelvin(self) -> int: + """Return the color temperature value in Kelvin.""" + return color_util.color_temperature_mired_to_kelvin( + self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE) + ) @property def color_mode(self) -> str: @@ -153,7 +173,7 @@ class HomeKitLight(HomeKitEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the specified light on.""" hs_color = kwargs.get(ATTR_HS_COLOR) - temperature = kwargs.get(ATTR_COLOR_TEMP) + temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) brightness = kwargs.get(ATTR_BRIGHTNESS) characteristics: dict[str, Any] = {} @@ -167,19 +187,18 @@ class HomeKitLight(HomeKitEntity, LightEntity): # does not support both, temperature will win. This is not # expected to happen in the UI, but it is possible via a manual # service call. - if temperature is not None: + if temperature_kelvin is not None: if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): - characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int( - temperature + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = ( + color_util.color_temperature_kelvin_to_mired(temperature_kelvin) ) + elif hs_color is None: # Some HomeKit devices implement color temperature with HS # since the spec "technically" does not permit the COLOR_TEMPERATURE # characteristic and the HUE and SATURATION characteristics to be # present at the same time. - hue_sat = color_util.color_temperature_to_hs( - color_util.color_temperature_mired_to_kelvin(temperature) - ) + hue_sat = color_util.color_temperature_to_hs(temperature_kelvin) characteristics[CharacteristicsTypes.HUE] = hue_sat[0] characteristics[CharacteristicsTypes.SATURATION] = hue_sat[1] diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index 2be28487cbb..6e16e16ba99 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -63,7 +63,6 @@ class HMThermostat(HMDevice, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index b05cc6a46d6..838cdc9c3c3 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -17,10 +17,14 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import color as color_util from .const import ATTR_DISCOVER_DEVICES from .entity import HMDevice +MAX_MIREDS = 500 # 2000 K +MIN_MIREDS = 153 # 6500 K + def setup_platform( hass: HomeAssistant, @@ -43,6 +47,9 @@ def setup_platform( class HMLight(HMDevice, LightEntity): """Representation of a Homematic light.""" + _attr_min_color_temp_kelvin = 2000 # 500 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -99,12 +106,14 @@ class HMLight(HMDevice, LightEntity): return hue * 360.0, sat * 100.0 @property - def color_temp(self): - """Return the color temp in mireds [int].""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if ColorMode.COLOR_TEMP not in self.supported_color_modes: return None hm_color_temp = self._hmdevice.get_color_temp(self._channel) - return self.max_mireds - (self.max_mireds - self.min_mireds) * hm_color_temp + return color_util.color_temperature_mired_to_kelvin( + MAX_MIREDS - (MAX_MIREDS - MIN_MIREDS) * hm_color_temp + ) @property def effect_list(self): @@ -130,7 +139,7 @@ class HMLight(HMDevice, LightEntity): self._hmdevice.set_level(percent_bright, self._channel) elif ( ATTR_HS_COLOR not in kwargs - and ATTR_COLOR_TEMP not in kwargs + and ATTR_COLOR_TEMP_KELVIN not in kwargs and ATTR_EFFECT not in kwargs ): self._hmdevice.on(self._channel) @@ -141,10 +150,11 @@ class HMLight(HMDevice, LightEntity): saturation=kwargs[ATTR_HS_COLOR][1] / 100.0, channel=self._channel, ) - if ATTR_COLOR_TEMP in kwargs: - hm_temp = (self.max_mireds - kwargs[ATTR_COLOR_TEMP]) / ( - self.max_mireds - self.min_mireds + if ATTR_COLOR_TEMP_KELVIN in kwargs: + mireds = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] ) + hm_temp = (MAX_MIREDS - mireds) / (MAX_MIREDS - MIN_MIREDS) self._hmdevice.set_color_temp(hm_temp) if ATTR_EFFECT in kwargs: self._hmdevice.set_effect(kwargs[ATTR_EFFECT]) diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index f6a69f50770..e7132fac83c 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -81,7 +81,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 7878a8b4e0a..a44d0586952 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.3"] + "requirements": ["homematicip==1.1.5"] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 24ed5933d06..8b822bffc50 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfReactivePower, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -565,7 +566,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", - native_unit_of_measurement="l/min", + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, has_fn=lambda data: data.active_liter_lpm is not None, value_fn=lambda data: data.active_liter_lpm, diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 5a4d6374304..a8ee5975914 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -26,10 +26,12 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] MIGRATE_OPTIONS_KEYS = {CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE} +type HoneywellConfigEntry = ConfigEntry[HoneywellData] + @callback def _async_migrate_data_to_options( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> None: if not MIGRATE_OPTIONS_KEYS.intersection(config_entry.data): return @@ -45,7 +47,9 @@ def _async_migrate_data_to_options( ) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: HoneywellConfigEntry +) -> bool: """Set up the Honeywell thermostat.""" _async_migrate_data_to_options(hass, config_entry) @@ -84,8 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if len(devices) == 0: _LOGGER.debug("No devices found") return False - data = HoneywellData(config_entry.entry_id, client, devices) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data + config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) @@ -93,19 +96,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: HoneywellConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HoneywellConfigEntry +) -> bool: """Unload the config and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) @dataclass diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index d4e5ee10a6b..7398ada23be 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -31,7 +31,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -40,7 +39,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter -from . import HoneywellData +from . import HoneywellConfigEntry, HoneywellData from .const import ( _LOGGER, CONF_COOL_AWAY_TEMPERATURE, @@ -97,13 +96,15 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: HoneywellConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" cool_away_temp = entry.options.get(CONF_COOL_AWAY_TEMPERATURE) heat_away_temp = entry.options.get(CONF_HEAT_AWAY_TEMPERATURE) - data: HoneywellData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data _async_migrate_unique_id(hass, data.devices) async_add_entities( [ @@ -131,7 +132,7 @@ def _async_migrate_unique_id( def remove_stale_devices( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, devices: dict[str, SomeComfortDevice], ) -> None: """Remove stale devices from device registry.""" @@ -164,7 +165,6 @@ class HoneywellUSThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_translation_key = "honeywell" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py index 35624c8fc39..b266e06d110 100644 --- a/homeassistant/components/honeywell/diagnostics.py +++ b/homeassistant/components/honeywell/diagnostics.py @@ -4,19 +4,17 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import HoneywellData -from .const import DOMAIN +from . import HoneywellConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - honeywell: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + honeywell = config_entry.runtime_data return { f"Device {device}": { diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index d0f0c8281f7..4a50e326965 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.25"] + "requirements": ["AIOSomecomfort==0.0.28"] } diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 31ed8d646c5..a9109d5d557 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -14,14 +14,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HoneywellData +from . import HoneywellConfigEntry from .const import DOMAIN OUTDOOR_TEMPERATURE_STATUS_KEY = "outdoor_temperature" @@ -81,11 +80,11 @@ SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" - data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data async_add_entities( HoneywellSensor(device, description) diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py index b90dd339593..3602dd1ba10 100644 --- a/homeassistant/components/honeywell/switch.py +++ b/homeassistant/components/honeywell/switch.py @@ -12,13 +12,12 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HoneywellData +from . import HoneywellConfigEntry, HoneywellData from .const import DOMAIN EMERGENCY_HEAT_KEY = "emergency_heat" @@ -34,11 +33,11 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell switches.""" - data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data async_add_entities( HoneywellSwitch(data, device, description) for device in data.devices.values() diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 40bdbb36261..2c68223581a 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -7,7 +7,7 @@ "vapid_prv_key": "VAPID private key" }, "data_description": { - "vapid_email": "Email to use for html5 push notifications.", + "vapid_email": "This contact address will be included in the metadata of each notification.", "vapid_prv_key": "If not specified, one will be automatically generated." } } diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 29c5840a4bf..9ca34af3741 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from pathlib import Path +import sys from typing import Final from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE @@ -17,6 +18,15 @@ CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER} RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512) +if sys.version_info >= (3, 13): + # guess_type is soft-deprecated in 3.13 + # for paths and should only be used for + # URLs. guess_file_type should be used + # for paths instead. + _GUESSER = CONTENT_TYPES.guess_file_type +else: + _GUESSER = CONTENT_TYPES.guess_type + class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" @@ -37,9 +47,7 @@ class CachingStaticResource(StaticResource): # Must be directory index; ignore caching return response file_path = response._path # noqa: SLF001 - response.content_type = ( - CONTENT_TYPES.guess_type(file_path)[0] or FALLBACK_CONTENT_TYPE - ) + response.content_type = _GUESSER(file_path)[0] or FALLBACK_CONTENT_TYPE # Cache actual header after setter construction. content_type = response.headers[CONTENT_TYPE] RESPONSE_CACHE[key] = (file_path, content_type) diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 76dd0fce12b..78a06784b8d 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -12,7 +12,7 @@ import aiohue from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -35,7 +35,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.util import color +from homeassistant.util import color as color_util from ..bridge import HueBridge from ..const import ( @@ -362,7 +362,7 @@ class HueLight(CoordinatorEntity, LightEntity): "bulb in the Philips Hue App." ) LOGGER.warning(err, self.name) - if self.gamut and not color.check_valid_gamut(self.gamut): + if self.gamut and not color_util.check_valid_gamut(self.gamut): err = "Color gamut of %s: %s, not valid, setting gamut to None." LOGGER.debug(err, self.name, str(self.gamut)) self.gamut_typ = GAMUT_TYPE_UNAVAILABLE @@ -427,49 +427,50 @@ class HueLight(CoordinatorEntity, LightEntity): source = self.light.action if self.is_group else self.light.state if mode in ("xy", "hs") and "xy" in source: - return color.color_xy_to_hs(*source["xy"], self.gamut) + return color_util.color_xy_to_hs(*source["xy"], self.gamut) return None @property - def color_temp(self): - """Return the CT color value.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" # Don't return color temperature unless in color temperature mode if self._color_mode != "ct": return None - if self.is_group: - return self.light.action.get("ct") - return self.light.state.get("ct") + ct = ( + self.light.action.get("ct") if self.is_group else self.light.state.get("ct") + ) + return color_util.color_temperature_mired_to_kelvin(ct) if ct else None @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if self.is_group: - return super().min_mireds + return super().max_color_temp_kelvin min_mireds = self.light.controlcapabilities.get("ct", {}).get("min") # We filter out '0' too, which can be incorrectly reported by 3rd party buls if not min_mireds: - return super().min_mireds + return super().max_color_temp_kelvin - return min_mireds + return color_util.color_temperature_mired_to_kelvin(min_mireds) @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if self.is_group: - return super().max_mireds + return super().min_color_temp_kelvin if self.is_livarno: return 500 max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") if not max_mireds: - return super().max_mireds + return super().min_color_temp_kelvin - return max_mireds + return color_util.color_temperature_mired_to_kelvin(max_mireds) @property def is_on(self): @@ -541,11 +542,14 @@ class HueLight(CoordinatorEntity, LightEntity): # Philips hue bulb models respond differently to hue/sat # requests, so we convert to XY first to ensure a consistent # color. - xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) + xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) command["xy"] = xy_color - elif ATTR_COLOR_TEMP in kwargs: - temp = kwargs[ATTR_COLOR_TEMP] - command["ct"] = max(self.min_mireds, min(temp, self.max_mireds)) + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + temp_k = max( + self.min_color_temp_kelvin, + min(self.max_color_temp_kelvin, kwargs[ATTR_COLOR_TEMP_KELVIN]), + ) + command["ct"] = color_util.color_temperature_kelvin_to_mired(temp_k) if ATTR_BRIGHTNESS in kwargs: command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS]) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 97ff6feffa5..c7f966ce9f2 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -12,7 +12,7 @@ from aiohue.v2.models.feature import DynamicStatus from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er +from homeassistant.util import color as color_util from ..bridge import HueBridge from ..const import DOMAIN @@ -157,7 +158,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): """Turn the grouped_light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) + color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) @@ -235,9 +236,21 @@ class GroupedHueLight(HueBaseEntity, LightEntity): if color_temp := light.color_temperature: lights_with_color_temp_support += 1 # we assume mired values from the first capable light - self._attr_color_temp = color_temp.mirek - self._attr_max_mireds = color_temp.mirek_schema.mirek_maximum - self._attr_min_mireds = color_temp.mirek_schema.mirek_minimum + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(color_temp.mirek) + if color_temp.mirek + else None + ) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_maximum + ) + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_minimum + ) + ) if color_temp.mirek is not None and color_temp.mirek_valid: lights_in_colortemp_mode += 1 if color := light.color: diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index 480296760e7..384d2a30596 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -2,6 +2,8 @@ from __future__ import annotations +from homeassistant.util import color as color_util + def normalize_hue_brightness(brightness: float | None) -> float | None: """Return calculated brightness values.""" @@ -21,10 +23,11 @@ def normalize_hue_transition(transition: float | None) -> float | None: return transition -def normalize_hue_colortemp(colortemp: int | None) -> int | None: +def normalize_hue_colortemp(colortemp_k: int | None) -> int | None: """Return color temperature within Hue's ranges.""" - if colortemp is not None: - # Hue only accepts a range between 153..500 - colortemp = min(colortemp, 500) - colortemp = max(colortemp, 153) - return colortemp + if colortemp_k is None: + return None + colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k) + # Hue only accepts a range between 153..500 + colortemp = min(colortemp, 500) + return max(colortemp, 153) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 053b3c19c2d..86d8cc93e54 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -13,7 +13,7 @@ from aiohue.v2.models.light import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_TRANSITION, @@ -28,6 +28,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from ..bridge import HueBridge from ..const import DOMAIN @@ -39,9 +40,9 @@ from .helpers import ( ) EFFECT_NONE = "None" -FALLBACK_MIN_MIREDS = 153 # 6500 K -FALLBACK_MAX_MIREDS = 500 # 2000 K -FALLBACK_MIREDS = 173 # halfway +FALLBACK_MIN_KELVIN = 6500 +FALLBACK_MAX_KELVIN = 2000 +FALLBACK_KELVIN = 5800 # halfway async def async_setup_entry( @@ -164,28 +165,32 @@ class HueLight(HueBaseEntity, LightEntity): return None @property - def color_temp(self) -> int: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if color_temp := self.resource.color_temperature: - return color_temp.mirek + return color_util.color_temperature_mired_to_kelvin(color_temp.mirek) # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MIREDS + return FALLBACK_KELVIN @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_minimum + return color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_minimum + ) # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MIN_MIREDS + return FALLBACK_MAX_KELVIN @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_maximum + return color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_maximum + ) # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MAX_MIREDS + return FALLBACK_MIN_KELVIN @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -210,7 +215,7 @@ class HueLight(HueBaseEntity, LightEntity): """Turn the device on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) + color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) if self._last_brightness and brightness is None: # The Hue bridge sets the brightness to 1% when turning on a bulb diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 1498c4f6e3d..8c892dca327 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -170,7 +170,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT ATTR_MAX_HUMIDITY: self.max_humidity, } - if HumidifierEntityFeature.MODES in self.supported_features_compat: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_AVAILABLE_MODES] = self.available_modes return data @@ -199,7 +199,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity - if HumidifierEntityFeature.MODES in self.supported_features_compat: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_MODE] = self.mode return data @@ -266,19 +266,6 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT """Return the list of supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> HumidifierEntityFeature: - """Return the supported features as HumidifierEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = HumidifierEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - async def async_service_humidity_set( entity: HumidifierEntity, service_call: ServiceCall diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 822f81f5f75..da7965250cd 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -62,7 +62,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> raise ConfigEntryAuthFailed from err raise ConfigEntryNotReady from err - coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) + if "amc:api" not in entry.data["token"]["scope"]: + # We raise ConfigEntryAuthFailed here because the websocket can't be used + # without the scope. So only polling would be possible. + raise ConfigEntryAuthFailed + + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) await coordinator.async_config_entry_first_refresh() available_devices = list(coordinator.data) cleanup_removed_devices(hass, coordinator.config_entry, available_devices) @@ -74,11 +79,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> "websocket_task", ) - if "amc:api" not in entry.data["token"]["scope"]: - # We raise ConfigEntryAuthFailed here because the websocket can't be used - # without the scope. So only polling would be possible. - raise ConfigEntryAuthFailed - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -89,7 +89,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) - def cleanup_removed_devices( - hass: HomeAssistant, config_entry: ConfigEntry, available_devices: list[str] + hass: HomeAssistant, + config_entry: AutomowerConfigEntry, + available_devices: list[str], ) -> None: """Cleanup entity and device registry from removed devices.""" device_reg = dr.async_get(hass) @@ -104,7 +106,7 @@ def cleanup_removed_devices( def remove_work_area_entities( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AutomowerConfigEntry, removed_work_areas: set[int], mower_id: str, ) -> None: diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index f8b8f155458..3c23da76797 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -30,6 +30,8 @@ from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index d4162af0c5c..f3e82fde5d4 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -15,6 +15,8 @@ from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 async def async_setup_entry( diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 4da3bd14089..7efed529453 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -53,10 +53,10 @@ class HusqvarnaConfigFlowHandler( tz = await dt_util.async_get_time_zone(str(dt_util.DEFAULT_TIME_ZONE)) automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz) try: - data = await automower_api.get_status() + status_data = await automower_api.get_status() except Exception: # noqa: BLE001 return self.async_abort(reason="unknown") - if data == {}: + if status_data == {}: return self.async_abort(reason="no_mower_connected") structured_token = structure_token(token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index c19f37a040d..57be02e7066 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -1,8 +1,11 @@ """Data UpdateCoordinator for the Husqvarna Automower integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import TYPE_CHECKING from aioautomower.exceptions import ( ApiException, @@ -13,13 +16,15 @@ from aioautomower.exceptions import ( from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +if TYPE_CHECKING: + from . import AutomowerConfigEntry + _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) @@ -29,11 +34,9 @@ DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): """Class to manage fetching Husqvarna data.""" - config_entry: ConfigEntry + config_entry: AutomowerConfigEntry - def __init__( - self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry - ) -> None: + def __init__(self, hass: HomeAssistant, api: AutomowerSession) -> None: """Initialize data updater.""" super().__init__( hass, @@ -66,7 +69,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib async def client_listen( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: AutomowerConfigEntry, automower_client: AutomowerSession, ) -> None: """Listen with the client.""" diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 5e84b7cc67d..520eaceb1d0 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -8,6 +8,9 @@ from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/husqvarna_automower/diagnostics.py b/homeassistant/components/husqvarna_automower/diagnostics.py index 658f6f94445..ceeec0f3e0d 100644 --- a/homeassistant/components/husqvarna_automower/diagnostics.py +++ b/homeassistant/components/husqvarna_automower/diagnostics.py @@ -6,7 +6,6 @@ import logging from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -26,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: AutomowerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return async_redact_data(entry.as_dict(), TO_REDACT) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index da6c0ae59ce..5b5156e5f1d 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -1,10 +1,12 @@ """Platform for Husqvarna Automower base entity.""" +from __future__ import annotations + import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate from aioautomower.exceptions import ApiException from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea @@ -52,18 +54,17 @@ def _work_area_translation_key(work_area_id: int, key: str) -> str: return f"work_area_{key}" -def handle_sending_exception( +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]] + + +def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P]( poll_after_sending: bool = False, -) -> Callable[ - [Callable[..., Awaitable[Any]]], Callable[..., Coroutine[Any, Any, None]] -]: +) -> Callable[[_FuncType[_Entity, _P, Any]], _FuncType[_Entity, _P, None]]: """Handle exceptions while sending a command and optionally refresh coordinator.""" - def decorator( - func: Callable[..., Awaitable[Any]], - ) -> Callable[..., Coroutine[Any, Any, None]]: + def decorator(func: _FuncType[_Entity, _P, Any]) -> _FuncType[_Entity, _P, None]: @functools.wraps(func) - async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) except ApiException as exception: @@ -133,7 +134,7 @@ class AutomowerControlEntity(AutomowerAvailableEntity): class WorkAreaAvailableEntity(AutomowerAvailableEntity): - """Base entity for work work areas.""" + """Base entity for work areas.""" def __init__( self, @@ -164,4 +165,4 @@ class WorkAreaAvailableEntity(AutomowerAvailableEntity): class WorkAreaControlEntity(WorkAreaAvailableEntity, AutomowerControlEntity): - """Base entity work work areas with control function.""" + """Base entity for work areas with control function.""" diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index d22d23583ba..02e87a3a772 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.10.3"] + "quality_scale": "silver", + "requirements": ["aioautomower==2024.12.0"] } diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml new file mode 100644 index 00000000000..2287ccb4d4f --- /dev/null +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: done + comment: | + The integration only has an entity service, registered in the platform. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: no configuration options + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: todo + comment: Discovery not implemented, yet. + discovery: + status: todo + comment: | + Most of the mowers are connected with a SIM card, some of the also have a + Wifi connection. Check, if discovery with Wifi is possible + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: Add devices dynamically + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: no configuration possible + repair-issues: done + stale-devices: + status: todo + comment: We only remove devices on reload + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 70b5510de36..fb8603623e4 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -35,6 +35,8 @@ from .entity import ( ) _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index df740aea3d1..7e0e4ce5ef1 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -56,7 +56,6 @@ class HuumDevice(ClimateEntity): _target_temperature: int | None = None _status: HuumStatusResponse | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, huum_handler: Huum, unique_id: str) -> None: """Initialize the heater.""" diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 9e402cd4932..ea5a5801e69 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN +from .const import APP_ID, DOMAIN from .coordinator import ( HydrawiseMainDataUpdateCoordinator, HydrawiseUpdateCoordinators, @@ -30,7 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryAuthFailed hydrawise = client.Hydrawise( - auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]) + auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]), + app_id=APP_ID, ) main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index 242763e81e3..5af32af3951 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -6,14 +6,14 @@ from collections.abc import Callable, Mapping from typing import Any from aiohttp import ClientError -from pydrawise import auth, client +from pydrawise import auth as pydrawise_auth, client from pydrawise.exceptions import NotAuthorizedError import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN, LOGGER +from .const import APP_ID, DOMAIN, LOGGER class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): @@ -29,16 +29,21 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): on_failure: Callable[[str], ConfigFlowResult], ) -> ConfigFlowResult: """Create the config entry.""" - # Verify that the provided credentials work.""" - api = client.Hydrawise(auth.Auth(username, password)) + auth = pydrawise_auth.Auth(username, password) try: - # Don't fetch zones because we don't need them yet. - user = await api.get_user(fetch_zones=False) + await auth.token() except NotAuthorizedError: return on_failure("invalid_auth") except TimeoutError: return on_failure("timeout_connect") + + try: + api = client.Hydrawise(auth, app_id=APP_ID) + # Don't fetch zones because we don't need them yet. + user = await api.get_user(fetch_zones=False) + except TimeoutError: + return on_failure("timeout_connect") except ClientError as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) return on_failure("cannot_connect") diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 6d846dd6127..beaf450a586 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -3,8 +3,12 @@ from datetime import timedelta import logging +from homeassistant.const import __version__ as HA_VERSION + LOGGER = logging.getLogger(__package__) +APP_ID = f"homeassistant-{HA_VERSION}" + DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = timedelta(minutes=15) diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 78da1eff071..53d1bce80de 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -54,7 +54,6 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, dev: AqualinkThermostat) -> None: """Initialize AquaLink thermostat.""" diff --git a/homeassistant/components/idasen_desk/quality_scale.yaml b/homeassistant/components/idasen_desk/quality_scale.yaml new file mode 100644 index 00000000000..28381f98a3e --- /dev/null +++ b/homeassistant/components/idasen_desk/quality_scale.yaml @@ -0,0 +1,108 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not use polling. + brands: done + common-modules: + status: todo + comment: | + The cover and sensor entities could move common initialization to a base entity class. + config-flow-test-coverage: + status: todo + comment: | + - use mock_desk_api + - merge test_user_step_auth_failed, test_user_step_cannot_connect and test_user_step_unknown_exception. + config-flow: + status: todo + comment: | + Missing data description for user step. + 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: todo + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide configuration parameters. + docs-installation-parameters: + status: exempt + comment: | + This integration does not provide installation parameters. + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: + status: todo + comment: | + - remove the await hass.async_block_till_done() after service calls with blocking=True + - use constants (like SERVICE_PRESS and ATTR_ENTITY_ID) in the tests calling services + - rename test_buttons.py -> test_button.py + - rename test_sensors.py -> test_sensor.py + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration uses Bluetooth and addresses don't change. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + This integration doesn't have any cases where a reconfiguration is needed. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration doesn't use websession. + strict-typing: todo diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index a31183f4489..0d20761c6e5 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -2,7 +2,6 @@ from __future__ import annotations -import math from typing import Any from iglo import Lamp @@ -11,7 +10,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, @@ -83,23 +82,19 @@ class IGloLamp(LightEntity): return ColorMode.HS @property - def color_temp(self): - """Return the color temperature.""" - return color_util.color_temperature_kelvin_to_mired(self._lamp.state()["white"]) + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return self._lamp.state()["white"] @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return math.ceil( - color_util.color_temperature_kelvin_to_mired(self._lamp.max_kelvin) - ) + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return self._lamp.max_kelvin @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return math.ceil( - color_util.color_temperature_kelvin_to_mired(self._lamp.min_kelvin) - ) + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return self._lamp.min_kelvin @property def hs_color(self): @@ -135,11 +130,8 @@ class IGloLamp(LightEntity): self._lamp.rgb(*rgb) return - if ATTR_COLOR_TEMP in kwargs: - kelvin = int( - color_util.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - ) - self._lamp.white(kelvin) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + self._lamp.white(kwargs[ATTR_COLOR_TEMP_KELVIN]) return if ATTR_EFFECT in kwargs: diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index dbb5962eabf..ea235127894 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -348,7 +348,7 @@ async def async_get_still_stream( # While this results in additional bandwidth usage, # given the low frequency of image updates, it is acceptable. frame.extend(frame) - await response.write(frame) + await response.write(frame) # type: ignore[arg-type] return True event = asyncio.Event() diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index b484586e057..60892388252 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -17,6 +17,9 @@ from . import ImapConfigEntry from .const import DOMAIN from .coordinator import ImapDataUpdateCoordinator +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( key="imap_mail_count", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index b5c35f3f1eb..ce3bc14d37b 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.6"] + "requirements": ["imgw_pib==1.0.7"] } diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 39e471b7614..4b6a6a5fcc3 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -4,33 +4,15 @@ from __future__ import annotations from aiohttp import ClientResponseError from incomfortclient import IncomfortError, InvalidHeaterList -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN from .coordinator import InComfortDataCoordinator, async_connect_gateway from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Inclusive(CONF_USERNAME, "credentials"): cv.string, - vol.Inclusive(CONF_PASSWORD, "credentials"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = ( Platform.WATER_HEATER, Platform.BINARY_SENSOR, @@ -43,53 +25,6 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Import config entry from configuration.yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - - -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Create an Intergas InComfort/Intouch system.""" - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" try: diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index eccf03588dc..41470180051 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -46,7 +46,6 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index e905f0d743d..f4838a9771d 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -81,11 +81,3 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import `incomfort` config entry from configuration.yaml.""" - errors: dict[str, str] | None = None - if (errors := await async_try_connect_gateway(self.hass, import_data)) is None: - return self.async_create_entry(title=TITLE, data=import_data) - reason = next(iter(errors.items()))[1] - return self.async_abort(reason=reason) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 40c93012eef..f404f33b970 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], - "requirements": ["incomfort-client==0.6.3-1"] + "requirements": ["incomfort-client==0.6.4"] } diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 3db8edbf1c9..506841e7efb 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -94,7 +94,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): _attr_hvac_modes = list(HVAC_MODES.values()) _attr_fan_modes = list(FAN_MODES.values()) _attr_min_humidity = 1 - _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index c13e22bf8c5..0f1c70b9ea8 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -56,7 +56,6 @@ class InsteonFanEntity(InsteonEntity, FanEntity): | FanEntityFeature.TURN_ON ) _attr_speed_count = 3 - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int | None: diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 4eddde5ff10..f72df254424 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -58,7 +58,6 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS last_temp = DEFAULT_THERMOSTAT_TEMP - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index dc2fc279a5d..c5bec07faaa 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -81,7 +81,6 @@ class IntellifireFan(IntellifireEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False @property def is_on(self) -> bool: diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 1ffb8747d91..71ef40ad369 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -139,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, TimerStatusIntentHandler()) intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) - intent.async_register(hass, HelloIntentHandler()) + intent.async_register(hass, RespondIntentHandler()) return True @@ -423,15 +423,25 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler): return response -class HelloIntentHandler(intent.IntentHandler): +class RespondIntentHandler(intent.IntentHandler): """Responds with no action.""" intent_type = intent.INTENT_RESPOND description = "Returns the provided response with no action." + slot_schema = { + vol.Optional("response"): cv.string, + } + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Return the provided response, but take no action.""" - return intent_obj.create_response() + slots = self.async_validate_slots(intent_obj.slots) + response = intent_obj.create_response() + + if "response" in slots: + response.async_set_speech(slots["response"]["value"]) + + return response async def _async_process_intent( diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 6f47cadb04f..a4f84f6ff9e 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -148,6 +148,8 @@ class ScriptIntentHandler(intent.IntentHandler): vol.Any("name", "area", "floor"): cv.string, vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, } def __init__(self, intent_type: str, config: ConfigType) -> None: @@ -205,7 +207,14 @@ class ScriptIntentHandler(intent.IntentHandler): ) if match_constraints.has_constraints: - match_result = intent.async_match_targets(hass, match_constraints) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id"), + floor_id=slots.get("preferred_floor_id"), + ) + + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) if match_result.is_match: targets = {} diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 82b653a34c7..1a1f58a6b80 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -147,7 +147,6 @@ class IntesisAC(ClimateEntity): _attr_should_poll = False _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, ih_device_id, ih_device, controller): """Initialize the thermostat.""" diff --git a/homeassistant/components/iotty/manifest.json b/homeassistant/components/iotty/manifest.json index 1c0d5cc3df2..5425ce3b480 100644 --- a/homeassistant/components/iotty/manifest.json +++ b/homeassistant/components/iotty/manifest.json @@ -1,11 +1,11 @@ { "domain": "iotty", "name": "iotty", - "codeowners": ["@pburgio", "@shapournemati-iotty"], + "codeowners": ["@shapournemati-iotty"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/iotty", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["iottycloud==0.2.1"] + "requirements": ["iottycloud==0.3.0"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 11c99a7428f..0236b72c89d 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.1.3", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.2.0", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 39bf39bcbe0..2765a14b7a3 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -194,9 +194,9 @@ class IrishRailTransportData: ATTR_STATION: self.station, ATTR_ORIGIN: "", ATTR_DESTINATION: dest, - ATTR_DUE_IN: "n/a", - ATTR_DUE_AT: "n/a", - ATTR_EXPECT_AT: "n/a", + ATTR_DUE_IN: None, + ATTR_DUE_AT: None, + ATTR_EXPECT_AT: None, ATTR_DIRECTION: direction, ATTR_STOPS_AT: stops_at, ATTR_TRAIN_TYPE: "", diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 35b426d11ab..0fe5acc2db6 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from aiogithubapi import GitHubAPI -from pynecil import Pynecil +from pynecil import IronOSUpdate, Pynecil from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -26,7 +25,12 @@ from .coordinator import ( IronOSSettingsCoordinator, ) -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.UPDATE] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SENSOR, + Platform.UPDATE, +] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -43,7 +47,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up IronOS firmware update coordinator.""" session = async_get_clientsession(hass) - github = GitHubAPI(session=session) + github = IronOSUpdate(session) hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) await hass.data[IRON_OS_KEY].async_request_refresh() diff --git a/homeassistant/components/iron_os/binary_sensor.py b/homeassistant/components/iron_os/binary_sensor.py new file mode 100644 index 00000000000..81ba0e08c95 --- /dev/null +++ b/homeassistant/components/iron_os/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for IronOS integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .coordinator import IronOSLiveDataCoordinator +from .entity import IronOSBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class PinecilBinarySensor(StrEnum): + """Pinecil Binary Sensors.""" + + TIP_CONNECTED = "tip_connected" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors from a config entry.""" + coordinator = entry.runtime_data.live_data + + entity_description = BinarySensorEntityDescription( + key=PinecilBinarySensor.TIP_CONNECTED, + translation_key=PinecilBinarySensor.TIP_CONNECTED, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ) + + async_add_entities([IronOSBinarySensorEntity(coordinator, entity_description)]) + + +class IronOSBinarySensorEntity(IronOSBaseEntity, BinarySensorEntity): + """Representation of a IronOS binary sensor entity.""" + + coordinator: IronOSLiveDataCoordinator + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.has_tip diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 690dd6f1893..e8ddef43bd7 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -5,15 +5,16 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging -from typing import TYPE_CHECKING -from aiogithubapi import GitHubAPI, GitHubException, GitHubReleaseModel from pynecil import ( CommunicationError, DeviceInfoResponse, + IronOSUpdate, + LatestRelease, LiveDataResponse, Pynecil, SettingsDataResponse, + UpdateException, ) from homeassistant.config_entries import ConfigEntry @@ -92,11 +93,22 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): except CommunicationError as e: raise UpdateFailed("Cannot connect to device") from e + @property + def has_tip(self) -> bool: + """Return True if the tip is connected.""" + if ( + self.data.max_tip_temp_ability is not None + and self.data.live_temp is not None + ): + threshold = self.data.max_tip_temp_ability - 5 + return self.data.live_temp <= threshold + return False -class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]): + +class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): """IronOS coordinator for retrieving update information from github.""" - def __init__(self, hass: HomeAssistant, github: GitHubAPI) -> None: + def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None: """Initialize IronOS coordinator.""" super().__init__( hass, @@ -107,21 +119,13 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]) ) self.github = github - async def _async_update_data(self) -> GitHubReleaseModel: + async def _async_update_data(self) -> LatestRelease: """Fetch data from Github.""" try: - release = await self.github.repos.releases.latest("Ralim/IronOS") - - except GitHubException as e: - raise UpdateFailed( - "Failed to retrieve latest release data from Github" - ) from e - - if TYPE_CHECKING: - assert release.data - - return release.data + return await self.github.latest_release() + except UpdateException as e: + raise UpdateFailed("Failed to check for latest IronOS update") from e class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 24d27457689..eadcc17bb37 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "tip_connected": { + "default": "mdi:pencil-outline", + "state": { + "off": "mdi:pencil-off-outline" + } + } + }, "number": { "setpoint_temperature": { "default": "mdi:thermometer" diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 3141273e3f0..8556d1e3609 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -12,6 +12,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", - "loggers": ["pynecil", "aiogithubapi"], - "requirements": ["pynecil==1.0.1", "aiogithubapi==24.6.0"] + "loggers": ["pynecil"], + "requirements": ["pynecil==2.1.0"] } diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml new file mode 100644 index 00000000000..5ede3d6971d --- /dev/null +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: Integration does register actions aside from entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: todo + test-before-setup: todo + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow + docs-installation-parameters: + status: todo + comment: Needs bluetooth address as parameter + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Devices don't require authentication + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Device is not connected to an ip network. Other information from discovery is immutable and does not require updating. + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Only one device per config entry. New devices are set up as new entries. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Reconfiguration would force a new config entry + repair-issues: + status: exempt + comment: no repairs/issues + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Device doesn't make http requests. + strict-typing: done diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py index 05d56db26d3..d178b46723f 100644 --- a/homeassistant/components/iron_os/sensor.py +++ b/homeassistant/components/iron_os/sensor.py @@ -28,8 +28,12 @@ from homeassistant.helpers.typing import StateType from . import IronOSConfigEntry from .const import OHM +from .coordinator import IronOSLiveDataCoordinator from .entity import IronOSBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + class PinecilSensor(StrEnum): """Pinecil Sensors.""" @@ -54,7 +58,7 @@ class PinecilSensor(StrEnum): class IronOSSensorEntityDescription(SensorEntityDescription): """IronOS sensor entity descriptions.""" - value_fn: Callable[[LiveDataResponse], StateType] + value_fn: Callable[[LiveDataResponse, bool], StateType] PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( @@ -64,7 +68,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.live_temp, + value_fn=lambda data, has_tip: data.live_temp if has_tip else None, ), IronOSSensorEntityDescription( key=PinecilSensor.DC_VOLTAGE, @@ -72,7 +76,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.dc_voltage, + value_fn=lambda data, _: data.dc_voltage, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -81,7 +85,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.handle_temp, + value_fn=lambda data, _: data.handle_temp, ), IronOSSensorEntityDescription( key=PinecilSensor.PWMLEVEL, @@ -90,7 +94,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( suggested_display_precision=0, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.pwm_level, + value_fn=lambda data, _: data.pwm_level, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -98,14 +102,16 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( translation_key=PinecilSensor.POWER_SRC, device_class=SensorDeviceClass.ENUM, options=[item.name.lower() for item in PowerSource], - value_fn=lambda data: data.power_src.name.lower() if data.power_src else None, + value_fn=( + lambda data, _: data.power_src.name.lower() if data.power_src else None + ), entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( key=PinecilSensor.TIP_RESISTANCE, translation_key=PinecilSensor.TIP_RESISTANCE, native_unit_of_measurement=OHM, - value_fn=lambda data: data.tip_resistance, + value_fn=lambda data, has_tip: data.tip_resistance if has_tip else None, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), @@ -115,7 +121,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.uptime, + value_fn=lambda data, _: data.uptime, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -124,7 +130,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.movement_time, + value_fn=lambda data, _: data.movement_time, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -132,7 +138,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( translation_key=PinecilSensor.MAX_TIP_TEMP_ABILITY, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda data: data.max_tip_temp_ability, + value_fn=lambda data, has_tip: data.max_tip_temp_ability if has_tip else None, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -142,7 +148,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - value_fn=lambda data: data.tip_voltage, + value_fn=lambda data, has_tip: data.tip_voltage if has_tip else None, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -150,7 +156,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( translation_key=PinecilSensor.HALL_SENSOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - value_fn=lambda data: data.hall_sensor, + value_fn=lambda data, _: data.hall_sensor, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -159,7 +165,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, options=[item.name.lower() for item in OperatingMode], value_fn=( - lambda data: data.operating_mode.name.lower() + lambda data, _: data.operating_mode.name.lower() if data.operating_mode else None ), @@ -170,7 +176,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.estimated_power, + value_fn=lambda data, _: data.estimated_power, ), ) @@ -193,8 +199,11 @@ class IronOSSensorEntity(IronOSBaseEntity, SensorEntity): """Representation of a IronOS sensor entity.""" entity_description: IronOSSensorEntityDescription + coordinator: IronOSLiveDataCoordinator @property def native_value(self) -> StateType: """Return sensor state.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn( + self.coordinator.data, self.coordinator.has_tip + ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index c474b704677..13528104f8c 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -20,6 +20,11 @@ } }, "entity": { + "binary_sensor": { + "tip_connected": { + "name": "Soldering tip" + } + }, "number": { "setpoint_temperature": { "name": "Setpoint temperature" diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml new file mode 100644 index 00000000000..b942ecba487 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no actions. + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: Group the 3 different executor jobs as one executor job + config-flow-test-coverage: + status: todo + comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth, + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration registers no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration registers no events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration registers no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + discovery: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: + status: done + comment: The default category is appropriate. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index 779a5d5c55f..eb06fabe373 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -40,6 +40,8 @@ from .coordinator import IstaCoordinator from .util import IstaConsumptionType, IstaValueType, get_native_value, get_statistics _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(kw_only=True, frozen=True) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index d4376b5a3b4..d5deba56284 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -88,7 +88,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): ) _attr_target_temperature_step = 1.0 _attr_fan_modes = [FAN_AUTO, FAN_ON] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 1d8af78f83c..fc0406e2d5f 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -53,7 +53,6 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int | None: diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py new file mode 100644 index 00000000000..b0a26cf7db2 --- /dev/null +++ b/homeassistant/components/ituran/__init__.py @@ -0,0 +1,28 @@ +"""The Ituran integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.DEVICE_TRACKER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool: + """Set up Ituran from a config entry.""" + + coordinator = IturanDataUpdateCoordinator(hass, entry=entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ituran/config_flow.py b/homeassistant/components/ituran/config_flow.py new file mode 100644 index 00000000000..9709e471503 --- /dev/null +++ b/homeassistant/components/ituran/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for Ituran integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pyituran import Ituran +from pyituran.exceptions import IturanApiError, IturanAuthError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult + +from .const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID_OR_PASSPORT): str, + vol.Required(CONF_PHONE_NUMBER): str, + } +) + +STEP_OTP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_OTP): str, + } +) + + +class IturanConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ituran.""" + + _user_info: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_ID_OR_PASSPORT]) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() + + ituran = Ituran( + user_input[CONF_ID_OR_PASSPORT], + user_input[CONF_PHONE_NUMBER], + ) + user_input[CONF_MOBILE_ID] = ituran.mobile_id + try: + authenticated = await ituran.is_authenticated() + if not authenticated: + await ituran.request_otp() + except IturanApiError: + errors["base"] = "cannot_connect" + except IturanAuthError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if authenticated: + return self.async_create_entry( + title=f"Ituran {user_input[CONF_ID_OR_PASSPORT]}", + data=user_input, + ) + self._user_info = user_input + return await self.async_step_otp() + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_otp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the OTP step.""" + errors: dict[str, str] = {} + if user_input is not None: + ituran = Ituran( + self._user_info[CONF_ID_OR_PASSPORT], + self._user_info[CONF_PHONE_NUMBER], + self._user_info[CONF_MOBILE_ID], + ) + try: + await ituran.authenticate(user_input[CONF_OTP]) + except IturanApiError: + errors["base"] = "cannot_connect" + except IturanAuthError: + errors["base"] = "invalid_otp" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._user_info + ) + return self.async_create_entry( + title=f"Ituran {self._user_info[CONF_ID_OR_PASSPORT]}", + data=self._user_info, + ) + + return self.async_show_form( + step_id="otp", data_schema=STEP_OTP_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self._user_info = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation message.""" + if user_input is not None: + return await self.async_step_user(self._user_info) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + description_placeholders={ + "phone_number": self._user_info[CONF_PHONE_NUMBER] + }, + ) diff --git a/homeassistant/components/ituran/const.py b/homeassistant/components/ituran/const.py new file mode 100644 index 00000000000..b17271490ee --- /dev/null +++ b/homeassistant/components/ituran/const.py @@ -0,0 +1,13 @@ +"""Constants for the Ituran integration.""" + +from datetime import timedelta +from typing import Final + +DOMAIN = "ituran" + +CONF_ID_OR_PASSPORT: Final = "id_or_passport" +CONF_PHONE_NUMBER: Final = "phone_number" +CONF_MOBILE_ID: Final = "mobile_id" +CONF_OTP: Final = "otp" + +UPDATE_INTERVAL = timedelta(seconds=300) diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py new file mode 100644 index 00000000000..cd0949eb4c2 --- /dev/null +++ b/homeassistant/components/ituran/coordinator.py @@ -0,0 +1,76 @@ +"""Coordinator for Ituran.""" + +import logging + +from pyituran import Ituran, Vehicle +from pyituran.exceptions import IturanApiError, IturanAuthError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_PHONE_NUMBER, + DOMAIN, + UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +type IturanConfigEntry = ConfigEntry[IturanDataUpdateCoordinator] + + +class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]): + """Class to manage fetching Ituran data.""" + + config_entry: IturanConfigEntry + + def __init__(self, hass: HomeAssistant, entry: IturanConfigEntry) -> None: + """Initialize account-wide Ituran data updater.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{entry.data[CONF_ID_OR_PASSPORT]}", + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + self.ituran = Ituran( + entry.data[CONF_ID_OR_PASSPORT], + entry.data[CONF_PHONE_NUMBER], + entry.data[CONF_MOBILE_ID], + ) + + async def _async_update_data(self) -> dict[str, Vehicle]: + """Fetch data from Ituran.""" + + try: + vehicles = await self.ituran.get_vehicles() + except IturanApiError as e: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="api_error" + ) from e + except IturanAuthError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="auth_error" + ) from e + + updated_data = {vehicle.license_plate: vehicle for vehicle in vehicles} + self._cleanup_removed_vehicles(updated_data) + + return updated_data + + def _cleanup_removed_vehicles(self, data: dict[str, Vehicle]) -> None: + account_vehicles = {(DOMAIN, license_plate) for license_plate in data} + device_registry = dr.async_get(self.hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=self.config_entry.entry_id + ) + for device in device_entries: + if not device.identifiers.intersection(account_vehicles): + device_registry.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py new file mode 100644 index 00000000000..37796570c61 --- /dev/null +++ b/homeassistant/components/ituran/device_tracker.py @@ -0,0 +1,49 @@ +"""Device tracker for Ituran vehicles.""" + +from __future__ import annotations + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ituran tracker from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanDeviceTracker(coordinator, license_plate) + for license_plate in coordinator.data + ) + + +class IturanDeviceTracker(IturanBaseEntity, TrackerEntity): + """Ituran device tracker.""" + + _attr_translation_key = "car" + _attr_name = None + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, license_plate, "device_tracker") + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.vehicle.gps_coordinates[0] + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.vehicle.gps_coordinates[1] diff --git a/homeassistant/components/ituran/entity.py b/homeassistant/components/ituran/entity.py new file mode 100644 index 00000000000..597cdac9513 --- /dev/null +++ b/homeassistant/components/ituran/entity.py @@ -0,0 +1,47 @@ +"""Base for all turan entities.""" + +from __future__ import annotations + +from pyituran import Vehicle + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IturanDataUpdateCoordinator + + +class IturanBaseEntity(CoordinatorEntity[IturanDataUpdateCoordinator]): + """Common base for Ituran entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + unique_key: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._license_plate = license_plate + self._attr_unique_id = f"{license_plate}-{unique_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vehicle.license_plate)}, + manufacturer=self.vehicle.make, + model=self.vehicle.model, + name=self.vehicle.model, + serial_number=self.vehicle.license_plate, + ) + + @property + def available(self) -> bool: + """Return True if vehicle is still included in the account.""" + return super().available and self._license_plate in self.coordinator.data + + @property + def vehicle(self) -> Vehicle: + """Return the vehicle information associated with this entity.""" + return self.coordinator.data[self._license_plate] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json new file mode 100644 index 00000000000..a20ea5b7304 --- /dev/null +++ b/homeassistant/components/ituran/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "device_tracker": { + "car": { + "default": "mdi:car" + } + } + } +} diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json new file mode 100644 index 00000000000..93860427a77 --- /dev/null +++ b/homeassistant/components/ituran/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ituran", + "name": "Ituran", + "codeowners": ["@shmuelzon"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ituran", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["pyituran==0.1.4"] +} diff --git a/homeassistant/components/ituran/quality_scale.yaml b/homeassistant/components/ituran/quality_scale.yaml new file mode 100644 index 00000000000..71d0d9698da --- /dev/null +++ b/homeassistant/components/ituran/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + reauthentication-flow: done + parallel-updates: + status: exempt + comment: | + Read only platforms and coordinator. + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + No options flow. + # Gold + entity-translations: done + entity-device-class: + status: exempt + comment: | + Only device_tracker platform. + devices: done + entity-category: todo + entity-disabled-by-default: + status: exempt + comment: | + No noisy entities + discovery: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users credentials to get the data. + stale-devices: todo + diagnostics: todo + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users credentials to get the data. + repair-issues: + status: exempt + comment: | + No repairs/issues. + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: todo + docs-examples: todo + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json new file mode 100644 index 00000000000..212dbd1b86a --- /dev/null +++ b/homeassistant/components/ituran/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "data": { + "id_or_passport": "ID or passport number", + "phone_number": "Mobile phone number" + }, + "data_description": { + "id_or_passport": "The government ID or passport number provided when registering with Ituran.", + "phone_number": "The mobile phone number provided when registering with Ituran. A one-time password will be sent to this mobile number." + } + }, + "otp": { + "data": { + "otp": "OTP" + }, + "data_description": { + "otp": "A one-time-password sent as a text message to the mobile phone number provided before." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "A new one-time password will be sent to {phone_number}." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_otp": "OTP invalid", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "exceptions": { + "api_error": { + "message": "An error occurred while communicating with the Ituran service." + }, + "auth_error": { + "message": "Failed authenticating with the Ituran service, please reauthenticate the integration." + } + } +} diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 2a602939250..e61917c825b 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -141,7 +141,6 @@ class ControllerDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_target_temperature_step = 0.5 - _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json index e5ffff68162..c8aa644333a 100644 --- a/homeassistant/components/kef/strings.json +++ b/homeassistant/components/kef/strings.json @@ -22,14 +22,14 @@ }, "high_pass": { "name": "High pass", - "description": "High-pass mode\"." + "description": "High-pass mode." }, "sub_polarity": { "name": "Subwoofer polarity", "description": "Sub polarity." }, "bass_extension": { - "name": "Base extension", + "name": "Bass extension", "description": "Bass extension." } } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 2c3887bb383..88d0c868636 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,8 +26,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -DOMAIN = "kitchen_sink" - +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.BUTTON, @@ -88,9 +87,27 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Start a reauth flow config_entry.async_start_reauth(hass) + # Notify backup listeners + hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) + return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + # Notify backup listeners + hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) + + return await hass.config_entries.async_unload_platforms( + entry, COMPONENTS_WITH_DEMO_PLATFORM + ) + + +async def _notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + def _create_issues(hass: HomeAssistant) -> None: """Create some issue registry issues.""" async_create_issue( diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py new file mode 100644 index 00000000000..615364f55ee --- /dev/null +++ b/homeassistant/components/kitchen_sink/backup.py @@ -0,0 +1,117 @@ +"""Backup platform for the kitchen_sink integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import Any + +from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder +from homeassistant.core import HomeAssistant, callback + +from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Register the backup agents.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + LOGGER.info("No config entry found or entry is not loaded") + return [] + return [KitchenSinkBackupAgent("syncer")] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + + return remove_listener + + +class KitchenSinkBackupAgent(BackupAgent): + """Kitchen sink backup agent.""" + + domain = DOMAIN + + def __init__(self, name: str) -> None: + """Initialize the kitchen sink backup sync agent.""" + super().__init__() + self.name = name + self._uploads = [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=False, + date="1970-01-01T00:00:00Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Kitchen sink syncer", + protected=False, + size=1234, + ) + ] + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + LOGGER.info("Downloading backup %s", backup_id) + reader = asyncio.StreamReader() + reader.feed_data(b"backup data") + reader.feed_eof() + return reader + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + LOGGER.info("Uploading backup %s %s", backup.backup_id, backup) + self._uploads.append(backup) + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + self._uploads = [ + upload for upload in self._uploads if upload.backup_id != backup_id + ] + LOGGER.info("Deleted backup %s", backup_id) + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List synced backups.""" + return self._uploads + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + for backup in self._uploads: + if backup.backup_id == backup_id: + return backup + return None diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py new file mode 100644 index 00000000000..e6edaca46ce --- /dev/null +++ b/homeassistant/components/kitchen_sink/const.py @@ -0,0 +1,12 @@ +"""Constants for the Kitchen Sink integration.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "kitchen_sink" +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py index 42c3956bd68..dfdf060e3b5 100644 --- a/homeassistant/components/knocki/__init__.py +++ b/homeassistant/components/knocki/__init__.py @@ -41,13 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_create_background_task( - hass, client.start_websocket(), "knocki-websocket" - ) + await client.start_websocket() return True async def async_unload_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.client.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index d9a45b18f0e..a91119ca831 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["knocki"], - "requirements": ["knocki==0.3.5"] + "requirements": ["knocki==0.4.2"] } diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 0e0da4d5c0c..af58dd6ef4d 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -148,7 +148,6 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): _device: XknxClimate _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "knx_climate" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize of a KNX climate device.""" diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index ce17517b970..75d91e48048 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -43,7 +43,6 @@ class KNXFan(KnxYamlEntity, FanEntity): """Representation of a KNX fan.""" _device: XknxFan - _enable_turn_on_off_backwards_compatibility = False def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize of KNX fan.""" diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index aed7f3ed455..55c19443aa0 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.3.0", + "xknx==3.4.0", "xknxproject==3.8.1", "knx-frontend==2024.11.16.205004" ], diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 113be9709ee..6c392902737 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -from functools import partial import logging from typing import TYPE_CHECKING @@ -47,14 +46,14 @@ def register_knx_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_KNX_SEND, - partial(service_send_to_knx_bus, hass), + service_send_to_knx_bus, schema=SERVICE_KNX_SEND_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_KNX_READ, - partial(service_read_to_knx_bus, hass), + service_read_to_knx_bus, schema=SERVICE_KNX_READ_SCHEMA, ) @@ -62,7 +61,7 @@ def register_knx_services(hass: HomeAssistant) -> None: hass, DOMAIN, SERVICE_KNX_EVENT_REGISTER, - partial(service_event_register_modify, hass), + service_event_register_modify, schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, ) @@ -70,7 +69,7 @@ def register_knx_services(hass: HomeAssistant) -> None: hass, DOMAIN, SERVICE_KNX_EXPOSURE_REGISTER, - partial(service_exposure_register_modify, hass), + service_exposure_register_modify, schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) @@ -78,7 +77,7 @@ def register_knx_services(hass: HomeAssistant) -> None: hass, DOMAIN, SERVICE_RELOAD, - partial(service_reload_integration, hass), + service_reload_integration, ) @@ -103,9 +102,9 @@ SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( ) -async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_event_register_modify(call: ServiceCall) -> None: """Service for adding or removing a GroupAddress to the knx_event filter.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) attr_address = call.data[KNX_ADDRESS] group_addresses = list(map(parse_device_group_address, attr_address)) @@ -156,11 +155,9 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( ) -async def service_exposure_register_modify( - hass: HomeAssistant, call: ServiceCall -) -> None: +async def service_exposure_register_modify(call: ServiceCall) -> None: """Service for adding or removing an exposure to KNX bus.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) group_address = call.data[KNX_ADDRESS] @@ -223,9 +220,9 @@ SERVICE_KNX_SEND_SCHEMA = vol.Any( ) -async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_send_to_knx_bus(call: ServiceCall) -> None: """Service for sending an arbitrary KNX message to the KNX bus.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) attr_address = call.data[KNX_ADDRESS] attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD] @@ -271,9 +268,9 @@ SERVICE_KNX_READ_SCHEMA = vol.Schema( ) -async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_read_to_knx_bus(call: ServiceCall) -> None: """Service for sending a GroupValueRead telegram to the KNX bus.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) for address in call.data[KNX_ADDRESS]: telegram = Telegram( @@ -284,8 +281,8 @@ async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> Non await knx_module.xknx.telegrams.put(telegram) -async def service_reload_integration(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_reload_integration(call: ServiceCall) -> None: """Reload the integration.""" - knx_module = get_knx_module(hass) - await hass.config_entries.async_reload(knx_module.entry.entry_id) - hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + knx_module = get_knx_module(call.hass) + await call.hass.config_entries.async_reload(knx_module.entry.entry_id) + call.hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index a69b97242f3..d20616e1940 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -3,10 +3,11 @@ import logging from packaging import version -from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient -from pylamarzocco.client_cloud import LaMarzoccoCloudClient -from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient +from pylamarzocco.clients.cloud import LaMarzoccoCloudClient +from pylamarzocco.clients.local import LaMarzoccoLocalClient from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.bluetooth import async_discovered_service_info @@ -22,10 +23,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN -from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator +from .coordinator import ( + LaMarzoccoConfigEntry, + LaMarzoccoConfigUpdateCoordinator, + LaMarzoccoFirmwareUpdateCoordinator, + LaMarzoccoRuntimeData, + LaMarzoccoStatisticsUpdateCoordinator, +) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -46,7 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - client = create_async_httpx_client(hass) + + client = async_create_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], @@ -98,18 +106,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - address_or_ble_device=entry.data[CONF_MAC], ) - coordinator = LaMarzoccoUpdateCoordinator( - hass=hass, - entry=entry, - local_client=local_client, + device = LaMarzoccoMachine( + model=entry.data[CONF_MODEL], + serial_number=entry.unique_id, + name=entry.data[CONF_NAME], cloud_client=cloud_client, + local_client=local_client, bluetooth_client=bluetooth_client, ) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + coordinators = LaMarzoccoRuntimeData( + LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), + LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device), + LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), + ) - gateway_version = coordinator.device.firmware[FirmwareType.GATEWAY].current_version + # API does not like concurrent requests, so no asyncio.gather here + await coordinators.config_coordinator.async_config_entry_first_refresh() + await coordinators.firmware_coordinator.async_config_entry_first_refresh() + await coordinators.statistics_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinators + + gateway_version = device.firmware[FirmwareType.GATEWAY].current_version if version.parse(gateway_version) < version.parse("v3.4-rc5"): # incompatible gateway firmware, create an issue ir.async_create_issue( diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 444e4d0723b..3d11992e7c1 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -17,6 +17,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoBinarySensorEntityDescription( @@ -61,7 +64,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary sensor entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator async_add_entities( LaMarzoccoBinarySensorEntity(coordinator, description) diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index dabf01d817d..22e92f656ff 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -57,7 +57,7 @@ async def async_setup_entry( ) -> None: """Set up button entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator async_add_entities( LaMarzoccoButtonEntity(coordinator, description) for description in ENTITIES diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 0ec9b55a9a1..1dcc7c324ac 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -13,6 +13,9 @@ from homeassistant.util import dt as dt_util from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + CALENDAR_KEY = "auto_on_off_schedule" DAY_OF_WEEK = [ @@ -33,7 +36,7 @@ async def async_setup_entry( ) -> None: """Set up switch entities and services.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator async_add_entities( LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry) for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values() diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 05dfcbc5196..5d927c6cc79 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -6,9 +6,9 @@ from collections.abc import Mapping import logging from typing import Any -from httpx import AsyncClient -from pylamarzocco.client_cloud import LaMarzoccoCloudClient -from pylamarzocco.client_local import LaMarzoccoLocalClient +from aiohttp import ClientSession +from pylamarzocco.clients.cloud import LaMarzoccoCloudClient +from pylamarzocco.clients.local import LaMarzoccoLocalClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import LaMarzoccoDeviceInfo import voluptuous as vol @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -58,7 +58,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 - _client: AsyncClient + _client: ClientSession def __init__(self) -> None: """Initialize the config flow.""" @@ -82,8 +82,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): **user_input, **self._discovered, } - self._client = create_async_httpx_client(self.hass) + self._client = async_create_clientsession(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 46a8e05745e..aca84fc4660 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -2,21 +2,18 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging -from time import time from typing import Any -from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient -from pylamarzocco.client_cloud import LaMarzoccoCloudClient -from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.clients.local import LaMarzoccoLocalClient +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine -from websockets.protocol import State from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,26 +21,35 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=30) -FIRMWARE_UPDATE_INTERVAL = 3600 -STATISTICS_UPDATE_INTERVAL = 300 - +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1) +STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) -type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoUpdateCoordinator] + +@dataclass +class LaMarzoccoRuntimeData: + """Runtime data for La Marzocco.""" + + config_coordinator: LaMarzoccoConfigUpdateCoordinator + firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator + statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator + + +type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData] class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to handle fetching data from the La Marzocco API centrally.""" + """Base class for La Marzocco coordinators.""" + _default_update_interval = SCAN_INTERVAL config_entry: LaMarzoccoConfigEntry def __init__( self, hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - cloud_client: LaMarzoccoCloudClient, - local_client: LaMarzoccoLocalClient | None, - bluetooth_client: LaMarzoccoBluetoothClient | None, + device: LaMarzoccoMachine, + local_client: LaMarzoccoLocalClient | None = None, ) -> None: """Initialize coordinator.""" super().__init__( @@ -51,24 +57,35 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): _LOGGER, config_entry=entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=self._default_update_interval, ) + self.device = device self.local_connection_configured = local_client is not None - - assert self.config_entry.unique_id - self.device = LaMarzoccoMachine( - model=self.config_entry.data[CONF_MODEL], - serial_number=self.config_entry.unique_id, - name=self.config_entry.data[CONF_NAME], - cloud_client=cloud_client, - local_client=local_client, - bluetooth_client=bluetooth_client, - ) - - self._last_firmware_data_update: float | None = None - self._last_statistics_data_update: float | None = None self._local_client = local_client + async def _async_update_data(self) -> None: + """Do the data update.""" + try: + await self._internal_async_update_data() + except AuthFail as ex: + _LOGGER.debug("Authentication failed", exc_info=True) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from ex + except RequestNotSuccessful as ex: + _LOGGER.debug(ex, exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex + + @abstractmethod + async def _internal_async_update_data(self) -> None: + """Actual data update logic.""" + + +class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Class to handle fetching data from the La Marzocco API centrally.""" + async def _async_setup(self) -> None: """Set up the coordinator.""" if self._local_client is not None: @@ -86,9 +103,8 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): if ( self._local_client is not None and self._local_client.websocket is not None - and self._local_client.websocket.state is State.OPEN + and not self._local_client.websocket.closed ): - self._local_client.terminating = True await self._local_client.websocket.close() self.config_entry.async_on_unload( @@ -98,41 +114,29 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): ) self.config_entry.async_on_unload(websocket_close) - async def _async_update_data(self) -> None: + async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" - await self._async_handle_request(self.device.get_config) - - if ( - self._last_firmware_data_update is None - or (self._last_firmware_data_update + FIRMWARE_UPDATE_INTERVAL) < time() - ): - await self._async_handle_request(self.device.get_firmware) - self._last_firmware_data_update = time() - - if ( - self._last_statistics_data_update is None - or (self._last_statistics_data_update + STATISTICS_UPDATE_INTERVAL) < time() - ): - await self._async_handle_request(self.device.get_statistics) - self._last_statistics_data_update = time() - + await self.device.get_config() _LOGGER.debug("Current status: %s", str(self.device.config)) - async def _async_handle_request[**_P]( - self, - func: Callable[_P, Coroutine[None, None, None]], - *args: _P.args, - **kwargs: _P.kwargs, - ) -> None: - try: - await func(*args, **kwargs) - except AuthFail as ex: - _LOGGER.debug("Authentication failed", exc_info=True) - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from ex - except RequestNotSuccessful as ex: - _LOGGER.debug(ex, exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="api_error" - ) from ex + +class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco firmware.""" + + _default_update_interval = FIRMWARE_UPDATE_INTERVAL + + async def _internal_async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.device.get_firmware() + _LOGGER.debug("Current firmware: %s", str(self.device.firmware)) + + +class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco statistics.""" + + _default_update_interval = STATISTICS_UPDATE_INTERVAL + + async def _internal_async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.device.get_statistics() + _LOGGER.debug("Current statistics: %s", str(self.device.statistics)) diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index 43ae51ee192..204a8b7142a 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -31,7 +31,7 @@ async def async_get_config_entry_diagnostics( entry: LaMarzoccoConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator device = coordinator.device # collect all data sources diagnostics_data = DiagnosticsData( diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 5542906d887..c3385eebd52 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from pylamarzocco.const import FirmwareType -from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 43b1c7deb47..0d2111a2026 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -36,5 +36,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], - "requirements": ["pylamarzocco==1.2.12"] + "requirements": ["pylamarzocco==1.4.0"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f32607fd73b..a1389769194 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -11,8 +11,8 @@ from pylamarzocco.const import ( PhysicalKey, PrebrewMode, ) +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.number import ( @@ -210,7 +210,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up number entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator entities: list[NumberEntity] = [ LaMarzoccoNumberEntity(coordinator, description) for description in ENTITIES diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 637ef935979..595c157b823 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from typing import Any from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -107,7 +107,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up select entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator async_add_entities( LaMarzoccoSelectEntity(coordinator, description) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 04b095e798c..8d57c1b8403 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey -from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( SensorDeviceClass, @@ -19,6 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoSensorEntityDescription( @@ -30,24 +33,6 @@ class LaMarzoccoSensorEntityDescription( ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( - LaMarzoccoSensorEntityDescription( - key="drink_stats_coffee", - translation_key="drink_stats_coffee", - native_unit_of_measurement="drinks", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - ), - LaMarzoccoSensorEntityDescription( - key="drink_stats_flushing", - translation_key="drink_stats_flushing", - native_unit_of_measurement="drinks", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.total_flushes, - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - ), LaMarzoccoSensorEntityDescription( key="shot_timer", translation_key="shot_timer", @@ -85,6 +70,27 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ), ) +STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="drink_stats_coffee", + translation_key="drink_stats_coffee", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="drink_stats_flushing", + translation_key="drink_stats_flushing", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.statistics.total_flushes, + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -92,14 +98,23 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor entities.""" - coordinator = entry.runtime_data + config_coordinator = entry.runtime_data.config_coordinator - async_add_entities( - LaMarzoccoSensorEntity(coordinator, description) + entities = [ + LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES - if description.supported_fn(coordinator) + if description.supported_fn(config_coordinator) + ] + + statistics_coordinator = entry.runtime_data.statistics_coordinator + entities.extend( + LaMarzoccoSensorEntity(statistics_coordinator, description) + for description in STATISTIC_ENTITIES + if description.supported_fn(statistics_coordinator) ) + async_add_entities(entities) + class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): """Sensor representing espresso machine temperature data.""" diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index 4dc701c4c29..54bd1ac2aed 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from typing import Any from pylamarzocco.const import BoilerType +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -68,7 +68,7 @@ async def async_setup_entry( ) -> None: """Set up switch entities and services.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator entities: list[SwitchEntity] = [] entities.extend( diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index ca182909042..0833ee6e249 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -59,7 +59,7 @@ async def async_setup_entry( ) -> None: """Create update entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.firmware_coordinator async_add_entities( LaMarzoccoUpdateEntity(coordinator, description) for description in ENTITIES diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 36dcdf26ed6..05c5dea77d1 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -249,7 +249,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): device = await lametric.device() if self.source != SOURCE_REAUTH: - await self.async_set_unique_id(device.serial_number) + await self.async_set_unique_id( + device.serial_number, + raise_on_progress=False, + ) self._abort_if_unique_id_configured( updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} ) diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py index 69c681e911a..c14ed998ace 100644 --- a/homeassistant/components/lametric/diagnostics.py +++ b/homeassistant/components/lametric/diagnostics.py @@ -26,5 +26,5 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Round-trip via JSON to trigger serialization - data = json.loads(coordinator.data.json()) + data = json.loads(coordinator.data.to_json()) return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index b0c6f8fd96e..5a066d015f2 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==0.4.0"], + "requirements": ["demetriek==1.1.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index 7362f0ca402..195924e2da5 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -5,12 +5,14 @@ from __future__ import annotations from typing import Any from demetriek import ( + AlarmSound, LaMetricDevice, LaMetricError, Model, Notification, NotificationIconType, NotificationPriority, + NotificationSound, Simple, Sound, ) @@ -18,8 +20,9 @@ from demetriek import ( from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import CONF_ICON from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.enum import try_parse_enum from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND, DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -53,7 +56,12 @@ class LaMetricNotificationService(BaseNotificationService): sound = None if CONF_SOUND in data: - sound = Sound(sound=data[CONF_SOUND], category=None) + snd: AlarmSound | NotificationSound | None + if (snd := try_parse_enum(AlarmSound, data[CONF_SOUND])) is None and ( + snd := try_parse_enum(NotificationSound, data[CONF_SOUND]) + ) is None: + raise ServiceValidationError("Unknown sound provided") + sound = Sound(sound=snd, category=None) notification = Notification( icon_type=NotificationIconType(data.get(CONF_ICON_TYPE, "none")), diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index cea9debb04b..1025e04a4a8 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] + has_fn: Callable[[Device], bool] = lambda device: True set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] @@ -49,7 +50,8 @@ NUMBERS = [ native_step=1, native_min_value=0, native_max_value=100, - value_fn=lambda device: device.audio.volume, + has_fn=lambda device: bool(device.audio), + value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), ), ] diff --git a/homeassistant/components/lametric/quality_scale.yaml b/homeassistant/components/lametric/quality_scale.yaml new file mode 100644 index 00000000000..a8982bb938b --- /dev/null +++ b/homeassistant/components/lametric/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: todo + comment: | + Device are documented, but some are missing. For example, the their pro + strip is supported as well. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/lametric/services.py b/homeassistant/components/lametric/services.py index d5191e0a434..2d9cd8f222d 100644 --- a/homeassistant/components/lametric/services.py +++ b/homeassistant/components/lametric/services.py @@ -19,8 +19,9 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_ICON from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.util.enum import try_parse_enum from .const import ( CONF_CYCLES, @@ -118,7 +119,12 @@ async def async_send_notification( """Send a notification to an LaMetric device.""" sound = None if CONF_SOUND in call.data: - sound = Sound(sound=call.data[CONF_SOUND], category=None) + snd: AlarmSound | NotificationSound | None + if (snd := try_parse_enum(AlarmSound, call.data[CONF_SOUND])) is None and ( + snd := try_parse_enum(NotificationSound, call.data[CONF_SOUND]) + ) is None: + raise ServiceValidationError("Unknown sound provided") + sound = Sound(sound=snd, category=None) notification = Notification( icon_type=NotificationIconType(call.data[CONF_ICON_TYPE]), diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 87bda01e305..0fd6f5a12dc 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -21,8 +21,11 @@ "api_key": "You can find this API key in [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)." } }, - "user_cloud_select_device": { + "cloud_select_device": { "data": { + "device": "Device" + }, + "data_description": { "device": "Select the LaMetric device to add" } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index 9689bb7b802..3aabfaf17e1 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -25,6 +25,7 @@ class LaMetricSwitchEntityDescription(SwitchEntityDescription): """Class describing LaMetric switch entities.""" available_fn: Callable[[Device], bool] = lambda device: True + has_fn: Callable[[Device], bool] = lambda device: True is_on_fn: Callable[[Device], bool] set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] @@ -34,8 +35,11 @@ SWITCHES = [ key="bluetooth", translation_key="bluetooth", entity_category=EntityCategory.CONFIG, - available_fn=lambda device: device.bluetooth.available, - is_on_fn=lambda device: device.bluetooth.active, + available_fn=lambda device: bool( + device.bluetooth and device.bluetooth.available + ), + has_fn=lambda device: bool(device.bluetooth), + is_on_fn=lambda device: bool(device.bluetooth and device.bluetooth.active), set_fn=lambda api, active: api.bluetooth(active=active), ), ] @@ -54,6 +58,7 @@ async def async_setup_entry( description=description, ) for description in SWITCHES + if description.has_fn(coordinator.data) ) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 1c7472bc4e3..360b732c02e 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -81,8 +81,6 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize of a LCN climate device.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 1d12e355a0d..4aaaebc0006 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.20.0", "led-ble==1.0.2"] + "requirements": ["bluetooth-data-tools==1.20.0", "led-ble==1.1.1"] } diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index daab1353098..6dd60909c66 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.1"] + "requirements": ["thinqconnect==1.0.2"] } diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index 138b9ba55bf..6cbb731869c 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -9,15 +9,11 @@ from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -46,21 +42,21 @@ class State(StrEnum): ROBOT_STATUS_TO_HA = { - "charging": STATE_DOCKED, - "diagnosis": STATE_IDLE, - "homing": STATE_RETURNING, - "initializing": STATE_IDLE, - "macrosector": STATE_IDLE, - "monitoring_detecting": STATE_IDLE, - "monitoring_moving": STATE_IDLE, - "monitoring_positioning": STATE_IDLE, - "pause": STATE_PAUSED, - "reservation": STATE_IDLE, - "setdate": STATE_IDLE, - "sleep": STATE_IDLE, - "standby": STATE_IDLE, - "working": STATE_CLEANING, - "error": STATE_ERROR, + "charging": VacuumActivity.DOCKED, + "diagnosis": VacuumActivity.IDLE, + "homing": VacuumActivity.RETURNING, + "initializing": VacuumActivity.IDLE, + "macrosector": VacuumActivity.IDLE, + "monitoring_detecting": VacuumActivity.IDLE, + "monitoring_moving": VacuumActivity.IDLE, + "monitoring_positioning": VacuumActivity.IDLE, + "pause": VacuumActivity.PAUSED, + "reservation": VacuumActivity.IDLE, + "setdate": VacuumActivity.IDLE, + "sleep": VacuumActivity.IDLE, + "standby": VacuumActivity.IDLE, + "working": VacuumActivity.CLEANING, + "error": VacuumActivity.ERROR, } ROBOT_BATT_TO_HA = { "moveless": 5, @@ -114,7 +110,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): super()._update_status() # Update state. - self._attr_state = ROBOT_STATUS_TO_HA[self.data.current_state] + self._attr_activity = ROBOT_STATUS_TO_HA[self.data.current_state] # Update battery. if (level := self.data.battery) is not None: @@ -135,7 +131,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): """Start the device.""" if self.data.current_state == State.SLEEP: value = State.WAKE_UP - elif self._attr_state == STATE_PAUSED: + elif self._attr_activity == VacuumActivity.PAUSED: value = State.RESUME else: value = State.START diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 9b213cc9f6d..667afe1125d 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -64,3 +64,6 @@ DATA_LIFX_MANAGER = "lifx_manager" LIFX_CEILING_PRODUCT_IDS = {176, 177} _LOGGER = logging.getLogger(__package__) + +# _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1 +_ATTR_COLOR_TEMP = "color_temp" diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 759d08707cd..27e62717e96 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -15,7 +15,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -30,7 +29,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids -from .const import ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN +from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN from .coordinator import LIFXUpdateCoordinator, Light from .util import convert_8_to_16, find_hsbk @@ -126,7 +125,8 @@ LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): vol.All( vol.Coerce(int), vol.Range(min=1500, max=9000) ), - vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int, + # _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1 + vol.Exclusive(_ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int, ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), ATTR_MODE: vol.In(PULSE_MODES), diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index c7d8a27a1c7..2e16eb2082b 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -23,6 +23,7 @@ "LIFX Ceiling", "LIFX Clean", "LIFX Color", + "LIFX Colour", "LIFX DLCOL", "LIFX Dlight", "LIFX DLWW", @@ -35,12 +36,14 @@ "LIFX Neon", "LIFX Nightvision", "LIFX PAR38", + "LIFX Permanent Outdoor", "LIFX Pls", "LIFX Plus", "LIFX Round", "LIFX Square", "LIFX String", "LIFX Tile", + "LIFX Tube", "LIFX White", "LIFX Z" ] @@ -48,7 +51,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.1", + "aiolifx==1.1.2", "aiolifx-effects==0.3.2", "aiolifx-themes==0.5.5" ] diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 9782fe4adba..ffffe7a4856 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -16,10 +16,8 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_XY_COLOR, ) @@ -29,6 +27,7 @@ from homeassistant.helpers import device_registry as dr import homeassistant.util.color as color_util from .const import ( + _ATTR_COLOR_TEMP, _LOGGER, DEFAULT_ATTEMPTS, DOMAIN, @@ -114,17 +113,14 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if ATTR_KELVIN in kwargs: + if _ATTR_COLOR_TEMP in kwargs: + # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( - "The 'kelvin' parameter is deprecated. Please use 'color_temp_kelvin' for" + "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" " all service calls" ) - kelvin = kwargs.pop(ATTR_KELVIN) - saturation = 0 - - if ATTR_COLOR_TEMP in kwargs: kelvin = color_util.color_temperature_mired_to_kelvin( - kwargs.pop(ATTR_COLOR_TEMP) + kwargs.pop(_ATTR_COLOR_TEMP) ) saturation = 0 diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 37ee6fe88fd..33bd259469b 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -5,11 +5,10 @@ from __future__ import annotations from collections.abc import Iterable import csv import dataclasses -from datetime import timedelta -from enum import IntFlag, StrEnum +from functools import partial import logging import os -from typing import Any, Self, cast, final +from typing import Any, Final, Self, cast, final from propcache import cached_property import voluptuous as vol @@ -24,97 +23,84 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass import homeassistant.util.color as color_util -from homeassistant.util.hass_dict import HassKey -DOMAIN = "light" -DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) +from .const import ( # noqa: F401 + COLOR_MODES_BRIGHTNESS, + COLOR_MODES_COLOR, + DATA_COMPONENT, + DATA_PROFILES, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, + DOMAIN, + SCAN_INTERVAL, + VALID_COLOR_MODES, + ColorMode, + LightEntityFeature, +) + ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE -SCAN_INTERVAL = timedelta(seconds=30) - -DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles") - - -class LightEntityFeature(IntFlag): - """Supported features of the light entity.""" - - EFFECT = 4 - FLASH = 8 - TRANSITION = 32 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the LightEntityFeature enum instead. -SUPPORT_BRIGHTNESS = 1 # Deprecated, replaced by color modes -SUPPORT_COLOR_TEMP = 2 # Deprecated, replaced by color modes -SUPPORT_EFFECT = 4 -SUPPORT_FLASH = 8 -SUPPORT_COLOR = 16 # Deprecated, replaced by color modes -SUPPORT_TRANSITION = 32 +_DEPRECATED_SUPPORT_BRIGHTNESS: Final = DeprecatedConstant( + 1, "supported_color_modes", "2026.1" +) # Deprecated, replaced by color modes +_DEPRECATED_SUPPORT_COLOR_TEMP: Final = DeprecatedConstant( + 2, "supported_color_modes", "2026.1" +) # Deprecated, replaced by color modes +_DEPRECATED_SUPPORT_EFFECT: Final = DeprecatedConstantEnum( + LightEntityFeature.EFFECT, "2026.1" +) +_DEPRECATED_SUPPORT_FLASH: Final = DeprecatedConstantEnum( + LightEntityFeature.FLASH, "2026.1" +) +_DEPRECATED_SUPPORT_COLOR: Final = DeprecatedConstant( + 16, "supported_color_modes", "2026.1" +) # Deprecated, replaced by color modes +_DEPRECATED_SUPPORT_TRANSITION: Final = DeprecatedConstantEnum( + LightEntityFeature.TRANSITION, "2026.1" +) # Color mode of the light ATTR_COLOR_MODE = "color_mode" # List of color modes supported by the light ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" - -class ColorMode(StrEnum): - """Possible light color modes.""" - - UNKNOWN = "unknown" - """Ambiguous color mode""" - ONOFF = "onoff" - """Must be the only supported mode""" - BRIGHTNESS = "brightness" - """Must be the only supported mode""" - COLOR_TEMP = "color_temp" - HS = "hs" - XY = "xy" - RGB = "rgb" - RGBW = "rgbw" - RGBWW = "rgbww" - WHITE = "white" - """Must *NOT* be the only supported mode""" - - # These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5. # Please use the LightEntityFeature enum instead. -COLOR_MODE_UNKNOWN = "unknown" -COLOR_MODE_ONOFF = "onoff" -COLOR_MODE_BRIGHTNESS = "brightness" -COLOR_MODE_COLOR_TEMP = "color_temp" -COLOR_MODE_HS = "hs" -COLOR_MODE_XY = "xy" -COLOR_MODE_RGB = "rgb" -COLOR_MODE_RGBW = "rgbw" -COLOR_MODE_RGBWW = "rgbww" -COLOR_MODE_WHITE = "white" +_DEPRECATED_COLOR_MODE_UNKNOWN: Final = DeprecatedConstantEnum( + ColorMode.UNKNOWN, "2026.1" +) +_DEPRECATED_COLOR_MODE_ONOFF: Final = DeprecatedConstantEnum(ColorMode.ONOFF, "2026.1") +_DEPRECATED_COLOR_MODE_BRIGHTNESS: Final = DeprecatedConstantEnum( + ColorMode.BRIGHTNESS, "2026.1" +) +_DEPRECATED_COLOR_MODE_COLOR_TEMP: Final = DeprecatedConstantEnum( + ColorMode.COLOR_TEMP, "2026.1" +) +_DEPRECATED_COLOR_MODE_HS: Final = DeprecatedConstantEnum(ColorMode.HS, "2026.1") +_DEPRECATED_COLOR_MODE_XY: Final = DeprecatedConstantEnum(ColorMode.XY, "2026.1") +_DEPRECATED_COLOR_MODE_RGB: Final = DeprecatedConstantEnum(ColorMode.RGB, "2026.1") +_DEPRECATED_COLOR_MODE_RGBW: Final = DeprecatedConstantEnum(ColorMode.RGBW, "2026.1") +_DEPRECATED_COLOR_MODE_RGBWW: Final = DeprecatedConstantEnum(ColorMode.RGBWW, "2026.1") +_DEPRECATED_COLOR_MODE_WHITE: Final = DeprecatedConstantEnum(ColorMode.WHITE, "2026.1") -VALID_COLOR_MODES = { - ColorMode.ONOFF, - ColorMode.BRIGHTNESS, - ColorMode.COLOR_TEMP, - ColorMode.HS, - ColorMode.XY, - ColorMode.RGB, - ColorMode.RGBW, - ColorMode.RGBWW, - ColorMode.WHITE, -} -COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF} -COLOR_MODES_COLOR = { - ColorMode.HS, - ColorMode.RGB, - ColorMode.RGBW, - ColorMode.RGBWW, - ColorMode.XY, -} # mypy: disallow-any-generics @@ -200,16 +186,26 @@ ATTR_RGBW_COLOR = "rgbw_color" ATTR_RGBWW_COLOR = "rgbww_color" ATTR_XY_COLOR = "xy_color" ATTR_HS_COLOR = "hs_color" -ATTR_COLOR_TEMP = "color_temp" # Deprecated in HA Core 2022.11 -ATTR_KELVIN = "kelvin" # Deprecated in HA Core 2022.11 -ATTR_MIN_MIREDS = "min_mireds" # Deprecated in HA Core 2022.11 -ATTR_MAX_MIREDS = "max_mireds" # Deprecated in HA Core 2022.11 ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin" ATTR_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" ATTR_COLOR_NAME = "color_name" ATTR_WHITE = "white" +# Deprecated in HA Core 2022.11 +_DEPRECATED_ATTR_COLOR_TEMP: Final = DeprecatedConstant( + "color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)", "2026.1" +) +_DEPRECATED_ATTR_KELVIN: Final = DeprecatedConstant( + "kelvin", "ATTR_COLOR_TEMP_KELVIN", "2026.1" +) +_DEPRECATED_ATTR_MIN_MIREDS: Final = DeprecatedConstant( + "min_mireds", "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", "2026.1" +) +_DEPRECATED_ATTR_MAX_MIREDS: Final = DeprecatedConstant( + "max_mireds", "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", "2026.1" +) + # Brightness of the light, 0..255 or percentage ATTR_BRIGHTNESS = "brightness" ATTR_BRIGHTNESS_PCT = "brightness_pct" @@ -254,11 +250,11 @@ LIGHT_TURN_ON_SCHEMA: VolDictType = { vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, - vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All( + vol.Exclusive(_DEPRECATED_ATTR_COLOR_TEMP.value, COLOR_GROUP): vol.All( vol.Coerce(int), vol.Range(min=1) ), vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int, - vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int, + vol.Exclusive(_DEPRECATED_ATTR_KELVIN.value, COLOR_GROUP): cv.positive_int, vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence( @@ -321,19 +317,29 @@ def preprocess_turn_on_alternatives( _LOGGER.warning("Got unknown color %s, falling back to white", color_name) params[ATTR_RGB_COLOR] = (255, 255, 255) - if (mired := params.pop(ATTR_COLOR_TEMP, None)) is not None: + if (mired := params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)) is not None: + _LOGGER.warning( + "Got `color_temp` argument in `turn_on` service, which is deprecated " + "and will break in Home Assistant 2026.1, please use " + "`color_temp_kelvin` argument" + ) kelvin = color_util.color_temperature_mired_to_kelvin(mired) - params[ATTR_COLOR_TEMP] = int(mired) + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired) params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) - if (kelvin := params.pop(ATTR_KELVIN, None)) is not None: + if (kelvin := params.pop(_DEPRECATED_ATTR_KELVIN.value, None)) is not None: + _LOGGER.warning( + "Got `kelvin` argument in `turn_on` service, which is deprecated " + "and will break in Home Assistant 2026.1, please use " + "`color_temp_kelvin` argument" + ) mired = color_util.color_temperature_kelvin_to_mired(kelvin) - params[ATTR_COLOR_TEMP] = int(mired) + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired) params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) if (kelvin := params.pop(ATTR_COLOR_TEMP_KELVIN, None)) is not None: mired = color_util.color_temperature_kelvin_to_mired(kelvin) - params[ATTR_COLOR_TEMP] = int(mired) + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired) params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) @@ -348,7 +354,7 @@ def filter_turn_off_params( if not params: return params - supported_features = light.supported_features_compat + supported_features = light.supported_features if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) @@ -360,7 +366,7 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features_compat + supported_features = light.supported_features if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) @@ -375,7 +381,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st if not brightness_supported(supported_color_modes): params.pop(ATTR_BRIGHTNESS, None) if ColorMode.COLOR_TEMP not in supported_color_modes: - params.pop(ATTR_COLOR_TEMP, None) + params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None) params.pop(ATTR_COLOR_TEMP_KELVIN, None) if ColorMode.HS not in supported_color_modes: params.pop(ATTR_HS_COLOR, None) @@ -457,7 +463,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: and ColorMode.COLOR_TEMP not in supported_color_modes and ColorMode.RGBWW in supported_color_modes ): - params.pop(ATTR_COLOR_TEMP) + params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) brightness = params.get(ATTR_BRIGHTNESS, light.brightness) params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( @@ -467,7 +473,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: light.max_color_temp_kelvin, ) elif ColorMode.COLOR_TEMP not in legacy_supported_color_modes: - params.pop(ATTR_COLOR_TEMP) + params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) if color_supported(legacy_supported_color_modes): params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs( @@ -514,8 +520,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: rgb_color = params.pop(ATTR_RGB_COLOR) @@ -537,8 +545,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) @@ -558,8 +568,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) @@ -579,8 +591,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes @@ -603,8 +617,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) # If white is set to True, set it to the light's brightness @@ -812,7 +828,7 @@ class Profiles: color_attributes = ( ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -860,13 +876,13 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): { ATTR_SUPPORTED_COLOR_MODES, ATTR_EFFECT_LIST, - ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, + _DEPRECATED_ATTR_MIN_MIREDS.value, + _DEPRECATED_ATTR_MAX_MIREDS.value, ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, @@ -880,17 +896,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): entity_description: LightEntityDescription _attr_brightness: int | None = None _attr_color_mode: ColorMode | str | None = None - _attr_color_temp: int | None = None _attr_color_temp_kelvin: int | None = None _attr_effect_list: list[str] | None = None _attr_effect: str | None = None _attr_hs_color: tuple[float, float] | None = None - # Default to the Philips Hue value that HA has always assumed - # https://developers.meethue.com/documentation/core-concepts + # We cannot set defaults without causing breaking changes until mireds + # are fully removed. Until then, developers can explicitly + # use DEFAULT_MIN_KELVIN and DEFAULT_MAX_KELVIN _attr_max_color_temp_kelvin: int | None = None _attr_min_color_temp_kelvin: int | None = None - _attr_max_mireds: int = 500 # 2000 K - _attr_min_mireds: int = 153 # 6500 K _attr_rgb_color: tuple[int, int, int] | None = None _attr_rgbw_color: tuple[int, int, int, int] | None = None _attr_rgbww_color: tuple[int, int, int, int, int] | None = None @@ -898,6 +912,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None + # Deprecated, see https://github.com/home-assistant/core/pull/79591 + _attr_color_temp: Final[int | None] = None + _attr_max_mireds: Final[int] = 500 # = 2000 K + _attr_min_mireds: Final[int] = 153 # = 6535.94 K (~ 6500 K) + __color_mode_reported = False @cached_property @@ -973,32 +992,70 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the rgbww color value [int, int, int, int, int].""" return self._attr_rgbww_color + @final @cached_property def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" + """Return the CT color value in mireds. + + Deprecated, see https://github.com/home-assistant/core/pull/79591 + """ return self._attr_color_temp @property def color_temp_kelvin(self) -> int | None: """Return the CT color value in Kelvin.""" if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp): + report_usage( + "is using mireds for current light color temperature, when " + "it should be adjusted to use the kelvin attribute " + "`_attr_color_temp_kelvin` or override the kelvin property " + "`color_temp_kelvin` (see " + "https://github.com/home-assistant/core/pull/79591)", + breaks_in_ha_version="2026.1", + core_behavior=ReportBehavior.LOG, + integration_domain=self.platform.platform_name + if self.platform + else None, + exclude_integrations={DOMAIN}, + ) return color_util.color_temperature_mired_to_kelvin(color_temp) return self._attr_color_temp_kelvin + @final @cached_property def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" + """Return the coldest color_temp that this light supports. + + Deprecated, see https://github.com/home-assistant/core/pull/79591 + """ return self._attr_min_mireds + @final @cached_property def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" + """Return the warmest color_temp that this light supports. + + Deprecated, see https://github.com/home-assistant/core/pull/79591 + """ return self._attr_max_mireds @property def min_color_temp_kelvin(self) -> int: """Return the warmest color_temp_kelvin that this light supports.""" if self._attr_min_color_temp_kelvin is None: + report_usage( + "is using mireds for warmest light color temperature, when " + "it should be adjusted to use the kelvin attribute " + "`_attr_min_color_temp_kelvin` or override the kelvin property " + "`min_color_temp_kelvin`, possibly with default DEFAULT_MIN_KELVIN " + "(see https://github.com/home-assistant/core/pull/79591)", + breaks_in_ha_version="2026.1", + core_behavior=ReportBehavior.LOG, + integration_domain=self.platform.platform_name + if self.platform + else None, + exclude_integrations={DOMAIN}, + ) return color_util.color_temperature_mired_to_kelvin(self.max_mireds) return self._attr_min_color_temp_kelvin @@ -1006,6 +1063,19 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def max_color_temp_kelvin(self) -> int: """Return the coldest color_temp_kelvin that this light supports.""" if self._attr_max_color_temp_kelvin is None: + report_usage( + "is using mireds for coldest light color temperature, when " + "it should be adjusted to use the kelvin attribute " + "`_attr_max_color_temp_kelvin` or override the kelvin property " + "`max_color_temp_kelvin`, possibly with default DEFAULT_MAX_KELVIN " + "(see https://github.com/home-assistant/core/pull/79591)", + breaks_in_ha_version="2026.1", + core_behavior=ReportBehavior.LOG, + integration_domain=self.platform.platform_name + if self.platform + else None, + exclude_integrations={DOMAIN}, + ) return color_util.color_temperature_mired_to_kelvin(self.min_mireds) return self._attr_max_color_temp_kelvin @@ -1023,7 +1093,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: @@ -1032,16 +1102,16 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin if not max_color_temp_kelvin: - data[ATTR_MIN_MIREDS] = None + data[_DEPRECATED_ATTR_MIN_MIREDS.value] = None else: - data[ATTR_MIN_MIREDS] = color_util.color_temperature_kelvin_to_mired( - max_color_temp_kelvin + data[_DEPRECATED_ATTR_MIN_MIREDS.value] = ( + color_util.color_temperature_kelvin_to_mired(max_color_temp_kelvin) ) if not min_color_temp_kelvin: - data[ATTR_MAX_MIREDS] = None + data[_DEPRECATED_ATTR_MAX_MIREDS.value] = None else: - data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( - min_color_temp_kelvin + data[_DEPRECATED_ATTR_MAX_MIREDS.value] = ( + color_util.color_temperature_kelvin_to_mired(min_color_temp_kelvin) ) if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list @@ -1185,12 +1255,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features supported_color_modes = self.supported_color_modes legacy_supported_color_modes = ( supported_color_modes or self._light_internal_supported_color_modes ) - supported_features_value = supported_features.value _is_on = self.is_on color_mode = self._light_internal_color_mode if _is_on else None @@ -1209,42 +1278,20 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - elif supported_features_value & SUPPORT_BRIGHTNESS: - # Backwards compatibility for ambiguous / incomplete states - # Warning is printed by supported_features_compat, remove in 2025.1 - if _is_on: - data[ATTR_BRIGHTNESS] = self.brightness - else: - data[ATTR_BRIGHTNESS] = None if color_temp_supported(supported_color_modes): if color_mode == ColorMode.COLOR_TEMP: color_temp_kelvin = self.color_temp_kelvin data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin if color_temp_kelvin: - data[ATTR_COLOR_TEMP] = ( + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) ) else: - data[ATTR_COLOR_TEMP] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None else: data[ATTR_COLOR_TEMP_KELVIN] = None - data[ATTR_COLOR_TEMP] = None - elif supported_features_value & SUPPORT_COLOR_TEMP: - # Backwards compatibility - # Warning is printed by supported_features_compat, remove in 2025.1 - if _is_on: - color_temp_kelvin = self.color_temp_kelvin - data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin - if color_temp_kelvin: - data[ATTR_COLOR_TEMP] = ( - color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) - ) - else: - data[ATTR_COLOR_TEMP] = None - else: - data[ATTR_COLOR_TEMP_KELVIN] = None - data[ATTR_COLOR_TEMP] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None if color_supported(legacy_supported_color_modes) or color_temp_supported( legacy_supported_color_modes @@ -1282,21 +1329,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): type(self), report_issue, ) - supported_features = self.supported_features_compat - supported_features_value = supported_features.value - supported_color_modes: set[ColorMode] = set() - - if supported_features_value & SUPPORT_COLOR_TEMP: - supported_color_modes.add(ColorMode.COLOR_TEMP) - if supported_features_value & SUPPORT_COLOR: - supported_color_modes.add(ColorMode.HS) - if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS: - supported_color_modes = {ColorMode.BRIGHTNESS} - - if not supported_color_modes: - supported_color_modes = {ColorMode.ONOFF} - - return supported_color_modes + return {ColorMode.ONOFF} @cached_property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: @@ -1308,40 +1341,17 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> LightEntityFeature: - """Return the supported features as LightEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is not int: # noqa: E721 - return features - new_features = LightEntityFeature(features) - if self._deprecated_supported_features_reported is True: - return new_features - self._deprecated_supported_features_reported = True - report_issue = self._suggest_report_issue() - report_issue += ( - " and reference " - "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" - ) - _LOGGER.warning( - ( - "Entity %s (%s) is using deprecated supported features" - " values which will be removed in HA Core 2025.1. Instead it should use" - " %s and color modes, please %s" - ), - self.entity_id, - type(self), - repr(new_features), - report_issue, - ) - return new_features - def __should_report_light_issue(self) -> bool: """Return if light color mode issues should be reported.""" if not self.platform: return True # philips_js has known issues, we don't need users to open issues return self.platform.platform_name not in {"philips_js"} + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/light/const.py b/homeassistant/components/light/const.py new file mode 100644 index 00000000000..d27750a950d --- /dev/null +++ b/homeassistant/components/light/const.py @@ -0,0 +1,73 @@ +"""Provides constants for lights.""" + +from __future__ import annotations + +from datetime import timedelta +from enum import IntFlag, StrEnum +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import LightEntity, Profiles + +DOMAIN = "light" +DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) +SCAN_INTERVAL = timedelta(seconds=30) + +DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles") + + +class LightEntityFeature(IntFlag): + """Supported features of the light entity.""" + + EFFECT = 4 + FLASH = 8 + TRANSITION = 32 + + +class ColorMode(StrEnum): + """Possible light color modes.""" + + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" + COLOR_TEMP = "color_temp" + HS = "hs" + XY = "xy" + RGB = "rgb" + RGBW = "rgbw" + RGBWW = "rgbww" + WHITE = "white" + """Must *NOT* be the only supported mode""" + + +VALID_COLOR_MODES = { + ColorMode.ONOFF, + ColorMode.BRIGHTNESS, + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.XY, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.WHITE, +} +COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF} +COLOR_MODES_COLOR = { + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.XY, +} + +# Default to the Philips Hue value that HA has always assumed +# https://developers.meethue.com/documentation/core-concepts +DEFAULT_MIN_KELVIN = 2000 # 500 mireds +DEFAULT_MAX_KELVIN = 6535 # 153 mireds diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 45e9731c5b8..56bf7485e68 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -27,14 +27,13 @@ from . import ( ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS_STEP_PCT, ATTR_FLASH, - DOMAIN, FLASH_SHORT, VALID_BRIGHTNESS_PCT, VALID_FLASH, - LightEntityFeature, brightness_supported, get_supported_color_modes, ) +from .const import DOMAIN, LightEntityFeature # mypy: disallow-any-generics diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index f9bb7c30bd7..6dc702f8551 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.condition import ConditionCheckerType from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index 033ea75357e..1f6bfdbe6e9 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -10,7 +10,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN TRIGGER_SCHEMA = vol.All( toggle_entity.TRIGGER_SCHEMA, diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 458dbbde770..e496255029a 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent import homeassistant.util.color as color_util -from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, DOMAIN +from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 4024f2f84ba..4e994ab791d 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -15,11 +15,13 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import Context, HomeAssistant, State +from homeassistant.util import color as color_util from . import ( + _DEPRECATED_ATTR_COLOR_TEMP, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -28,9 +30,8 @@ from . import ( ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, - DOMAIN, - ColorMode, ) +from .const import DOMAIN, ColorMode _LOGGER = logging.getLogger(__name__) @@ -40,7 +41,8 @@ ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT] COLOR_GROUP = [ ATTR_HS_COLOR, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -56,7 +58,7 @@ class ColorModeAttr(NamedTuple): COLOR_MODE_TO_ATTRIBUTE = { - ColorMode.COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), + ColorMode.COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN), ColorMode.HS: ColorModeAttr(ATTR_HS_COLOR, ATTR_HS_COLOR), ColorMode.RGB: ColorModeAttr(ATTR_RGB_COLOR, ATTR_RGB_COLOR), ColorMode.RGBW: ColorModeAttr(ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), @@ -125,13 +127,30 @@ async def _async_reproduce_state( color_mode = state.attributes[ATTR_COLOR_MODE] if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None: + if ( + color_mode != ColorMode.COLOR_TEMP + or ( + mireds := state.attributes.get( + _DEPRECATED_ATTR_COLOR_TEMP.value + ) + ) + is None + ): + _LOGGER.warning( + "Color mode %s specified but attribute %s missing for: %s", + color_mode, + cm_attr.state_attr, + state.entity_id, + ) + return _LOGGER.warning( - "Color mode %s specified but attribute %s missing for: %s", + "Color mode %s specified but attribute %s missing for: %s, " + "using color_temp (mireds) as fallback", color_mode, cm_attr.state_attr, state.entity_id, ) - return + cm_attr_state = color_util.color_temperature_mired_to_kelvin(mireds) service_data[cm_attr.parameter] = cm_attr_state else: # Fall back to Choosing the first color that is specified diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py index 1877c925622..773b7a6b898 100644 --- a/homeassistant/components/light/significant_change.py +++ b/homeassistant/components/light/significant_change.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.significant_change import check_absolute_change -from . import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR +from . import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR @callback @@ -44,10 +44,10 @@ def async_check_significant_change( return True if check_absolute_change( - # Default range 153..500 - old_attrs.get(ATTR_COLOR_TEMP), - new_attrs.get(ATTR_COLOR_TEMP), - 5, + # Default range 2000..6500 + old_attrs.get(ATTR_COLOR_TEMP_KELVIN), + new_attrs.get(ATTR_COLOR_TEMP_KELVIN), + 50, ): return True diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 1016e8ce80d..942fb4a1fbc 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -55,7 +55,6 @@ class LightwaveTrv(ClimateEntity): ) _attr_target_temperature_step = 0.5 _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, device_id, lwlink, serial): """Initialize LightwaveTrv entity.""" diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index c6b3301081d..4b2b75be9d7 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -38,7 +38,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin +from homeassistant.util.color import color_hs_to_RGB _LOGGER = logging.getLogger(__name__) @@ -217,8 +217,8 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): """Representation of a LimitessLED group.""" _attr_assumed_state = True - _attr_max_mireds = 370 - _attr_min_mireds = 154 + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 154 Mireds _attr_should_poll = False def __init__(self, group: Group, config: dict[str, Any]) -> None: @@ -261,7 +261,9 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): if last_state := await self.async_get_last_state(): self._attr_is_on = last_state.state == STATE_ON self._attr_brightness = last_state.attributes.get("brightness") - self._attr_color_temp = last_state.attributes.get("color_temp") + self._attr_color_temp_kelvin = last_state.attributes.get( + "color_temp_kelvin" + ) self._attr_hs_color = last_state.attributes.get("hs_color") @property @@ -325,12 +327,12 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): else: args["color"] = self.limitlessled_color() - if ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN in kwargs: assert self.supported_color_modes if ColorMode.HS in self.supported_color_modes: pipeline.white() self._attr_hs_color = WHITE - self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] + self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] args["temperature"] = self.limitlessled_temperature() if args: @@ -354,12 +356,9 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): def limitlessled_temperature(self) -> float: """Convert Home Assistant color temperature units to percentage.""" - max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds) - min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds) - width = max_kelvin - min_kelvin - assert self.color_temp is not None - kelvin = color_temperature_mired_to_kelvin(self.color_temp) - temperature = (kelvin - min_kelvin) / width + width = self.max_color_temp_kelvin - self.min_color_temp_kelvin + assert self.color_temp_kelvin is not None + temperature = (self.color_temp_kelvin - self.min_color_temp_kelvin) / width return max(0, min(1, temperature)) def limitlessled_brightness(self) -> float: diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index e74d22b8207..cc124ceb611 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.20"], + "requirements": ["python-linkplay==0.1.1"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index f5553bf5d49..bd00c328233 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -10,12 +10,9 @@ from pylitterbot.enums import LitterBoxStatus import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_PAUSED, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant @@ -29,16 +26,16 @@ from .entity import LitterRobotEntity SERVICE_SET_SLEEP_MODE = "set_sleep_mode" LITTER_BOX_STATUS_STATE_MAP = { - LitterBoxStatus.CLEAN_CYCLE: STATE_CLEANING, - LitterBoxStatus.EMPTY_CYCLE: STATE_CLEANING, - LitterBoxStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED, - LitterBoxStatus.CAT_DETECTED: STATE_DOCKED, - LitterBoxStatus.CAT_SENSOR_TIMING: STATE_DOCKED, - LitterBoxStatus.DRAWER_FULL_1: STATE_DOCKED, - LitterBoxStatus.DRAWER_FULL_2: STATE_DOCKED, - LitterBoxStatus.READY: STATE_DOCKED, - LitterBoxStatus.CAT_SENSOR_INTERRUPTED: STATE_PAUSED, - LitterBoxStatus.OFF: STATE_DOCKED, + LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING, + LitterBoxStatus.EMPTY_CYCLE: VacuumActivity.CLEANING, + LitterBoxStatus.CLEAN_CYCLE_COMPLETE: VacuumActivity.DOCKED, + LitterBoxStatus.CAT_DETECTED: VacuumActivity.DOCKED, + LitterBoxStatus.CAT_SENSOR_TIMING: VacuumActivity.DOCKED, + LitterBoxStatus.DRAWER_FULL_1: VacuumActivity.DOCKED, + LitterBoxStatus.DRAWER_FULL_2: VacuumActivity.DOCKED, + LitterBoxStatus.READY: VacuumActivity.DOCKED, + LitterBoxStatus.CAT_SENSOR_INTERRUPTED: VacuumActivity.PAUSED, + LitterBoxStatus.OFF: VacuumActivity.DOCKED, } LITTER_BOX_ENTITY = StateVacuumEntityDescription( @@ -78,9 +75,9 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): ) @property - def state(self) -> str: + def activity(self) -> VacuumActivity: """Return the state of the cleaner.""" - return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, STATE_ERROR) + return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR) @property def status(self) -> str: diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 5d70936fc53..3ecdcb486c0 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -68,7 +68,6 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 9363d388637..39d5d3c350d 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -285,12 +285,7 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" - features = self._attr_supported_features - if type(features) is int: # noqa: E721 - new_features = LockEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features + return self._attr_supported_features async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index fadeb6d16fa..051a18c9a32 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -107,7 +107,6 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): _attr_min_temp = MIN_TEMP _attr_max_temp = MAX_TEMP _attr_target_temperature_step = PRECISION_WHOLE - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index dc881b393de..7db8b12c8d0 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -51,7 +51,6 @@ class LutronFan(LutronDevice, FanEntity): ) _lutron_device: Output _prev_percentage: int | None = None - _enable_turn_on_off_backwards_compatibility = False def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index e2bf7f15098..69167929e14 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -50,7 +50,6 @@ class LutronCasetaFan(LutronCasetaUpdatableEntity, FanEntity): | FanEntityFeature.TURN_ON ) _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int | None: diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index bf8e17527e8..87b5d566bb8 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -174,7 +174,6 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index e8d23434248..f7f974ffbb0 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -81,7 +81,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> ) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: """Migrate old config.""" if entry.version == 1 and entry.minor_version == 1: @@ -113,7 +113,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def setup_mastodon(entry: ConfigEntry) -> tuple[Mastodon, dict, dict]: +def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]: """Get mastodon details.""" client = create_mastodon_client( entry.data[CONF_BASE_URL], diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 7c0985570f7..a36ba2e917f 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -8,7 +8,7 @@ from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError import voluptuous as vol from yarl import URL -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -53,7 +53,6 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 2 - config_entry: ConfigEntry def check_connection( self, diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml new file mode 100644 index 00000000000..315ef808701 --- /dev/null +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: todo + comment: | + Mastodon.py does not have CI build/publish. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: | + Legacy Notify needs rewriting once Notify architecture stabilizes. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + There are no configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: todo + comment: | + Does not set parallel-updates on notify platform. + reauthentication-flow: + status: todo + comment: | + Waiting to move to oAuth. + test-coverage: + status: todo + comment: | + Legacy Notify needs rewriting once Notify architecture stabilizes. + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + Web service does not support discovery. + discovery: + status: exempt + comment: | + Web service does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single web service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + Waiting to move to OAuth. + repair-issues: done + stale-devices: + status: exempt + comment: | + Web service does not go stale. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index a7a1d40fcc4..1bb59ad7c05 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -23,6 +23,9 @@ from .const import ( ) from .entity import MastodonEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MastodonSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index cdbe1e36245..0378d0ea226 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -187,7 +187,7 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF _feature_map: int | None = None - _enable_turn_on_off_backwards_compatibility = False + _platform_translation_key = "thermostat" async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 51c2fb0c882..593693dbbf9 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -58,7 +58,7 @@ class MatterFan(MatterEntity, FanEntity): _last_known_preset_mode: str | None = None _last_known_percentage: int = 0 - _enable_turn_on_off_backwards_compatibility = False + _feature_map: int | None = None _platform_translation_key = "fan" diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 32c9f057e47..adcdcd05137 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -43,6 +43,9 @@ "air_quality": { "default": "mdi:air-filter" }, + "bat_replacement_description": { + "default": "mdi:battery-sync" + }, "hepa_filter_condition": { "default": "mdi:filter-check" }, diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 6d184bcc01f..c9d5c688f69 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -9,10 +9,12 @@ from matter_server.client.models import device_types from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, LightEntityDescription, @@ -23,6 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .const import LOGGER from .entity import MatterEntity @@ -90,6 +93,8 @@ class MatterLight(MatterEntity, LightEntity): _supports_color_temperature = False _transitions_disabled = False _platform_translation_key = "light" + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN async def _set_xy_color( self, xy_color: tuple[float, float], transition: float = 0.0 @@ -131,12 +136,16 @@ class MatterLight(MatterEntity, LightEntity): ) ) - async def _set_color_temp(self, color_temp: int, transition: float = 0.0) -> None: + async def _set_color_temp( + self, color_temp_kelvin: int, transition: float = 0.0 + ) -> None: """Set color temperature.""" - + color_temp_mired = color_util.color_temperature_kelvin_to_mired( + color_temp_kelvin + ) await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( - colorTemperatureMireds=color_temp, + colorTemperatureMireds=color_temp_mired, # transition in matter is measured in tenths of a second transitionTime=int(transition * 10), # allow setting the color while the light is off, @@ -286,7 +295,7 @@ class MatterLight(MatterEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) brightness = kwargs.get(ATTR_BRIGHTNESS) transition = kwargs.get(ATTR_TRANSITION, 0) if self._transitions_disabled: @@ -298,10 +307,10 @@ class MatterLight(MatterEntity, LightEntity): elif xy_color is not None and ColorMode.XY in self.supported_color_modes: await self._set_xy_color(xy_color, transition) elif ( - color_temp is not None + color_temp_kelvin is not None and ColorMode.COLOR_TEMP in self.supported_color_modes ): - await self._set_color_temp(color_temp, transition) + await self._set_color_temp(color_temp_kelvin, transition) if brightness is not None and self._supports_brightness: await self._set_brightness(brightness, transition) @@ -368,12 +377,16 @@ class MatterLight(MatterEntity, LightEntity): clusters.ColorControl.Attributes.ColorTempPhysicalMinMireds ) if min_mireds > 0: - self._attr_min_mireds = min_mireds + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + ) max_mireds = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorTempPhysicalMaxMireds ) - if min_mireds > 0: - self._attr_max_mireds = max_mireds + if max_mireds > 0: + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + ) supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes @@ -399,8 +412,13 @@ class MatterLight(MatterEntity, LightEntity): if self._supports_brightness: self._attr_brightness = self._get_brightness() - if self._supports_color_temperature: - self._attr_color_temp = self._get_color_temperature() + if ( + self._supports_color_temperature + and (color_temperature := self._get_color_temperature()) > 0 + ): + self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + color_temperature + ) if self._supports_color: self._attr_color_mode = color_mode = self._get_color_mode() @@ -414,7 +432,7 @@ class MatterLight(MatterEntity, LightEntity): and color_mode == ColorMode.XY ): self._attr_xy_color = self._get_xy_color() - elif self._attr_color_temp is not None: + elif self._attr_color_temp_kelvin is not None: self._attr_color_mode = ColorMode.COLOR_TEMP elif self._attr_brightness is not None: self._attr_color_mode = ColorMode.BRIGHTNESS diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index e10f081d497..d71cd52a0c6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -231,6 +231,20 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatVoltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatReplacementDescription", + translation_key="battery_replacement_description", + native_unit_of_measurement=None, + device_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.PowerSource.Attributes.BatReplacementDescription, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -612,11 +626,12 @@ DISCOVERY_SCHEMAS = [ key="ElectricalEnergyMeasurementCumulativeEnergyImported", device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) - measurement_to_ha=lambda x: x.energy / 1000000, + measurement_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, required_attributes=( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 69fa68765b3..ca15538997e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -245,6 +245,9 @@ }, "valve_position": { "name": "Valve position" + }, + "battery_replacement_description": { + "name": "Battery type" } }, "switch": { diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 2ecd7128df6..e98e1ad0bbd 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -9,16 +9,13 @@ from chip.clusters import Objects as clusters from matter_server.client.models import device_types from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_IDLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -127,25 +124,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): operational_state: int = self.get_matter_attribute_value( clusters.RvcOperationalState.Attributes.OperationalState ) - state: str | None = None + state: VacuumActivity | None = None if TYPE_CHECKING: assert self._supported_run_modes is not None if operational_state in (OperationalState.CHARGING, OperationalState.DOCKED): - state = STATE_DOCKED + state = VacuumActivity.DOCKED elif operational_state == OperationalState.SEEKING_CHARGER: - state = STATE_RETURNING + state = VacuumActivity.RETURNING elif operational_state in ( OperationalState.UNABLE_TO_COMPLETE_OPERATION, OperationalState.UNABLE_TO_START_OR_RESUME, ): - state = STATE_ERROR + state = VacuumActivity.ERROR elif (run_mode := self._supported_run_modes.get(run_mode_raw)) is not None: tags = {x.value for x in run_mode.modeTags} if ModeTag.CLEANING in tags: - state = STATE_CLEANING + state = VacuumActivity.CLEANING elif ModeTag.IDLE in tags: - state = STATE_IDLE - self._attr_state = state + state = VacuumActivity.IDLE + self._attr_activity = state @callback def _calculate_features(self) -> None: diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index b14efbbe073..da5a9f34dda 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -73,7 +73,6 @@ class MaxCubeClimate(ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, handler, device): """Initialize MAX! Cube ClimateEntity.""" diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 866215839bf..21c07607573 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.11.18"], + "requirements": ["yt-dlp[default]==2024.12.13"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 291b1ec1e2a..e7bbe1d19bd 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -773,19 +773,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> MediaPlayerEntityFeature: - """Return the supported features as MediaPlayerEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = MediaPlayerEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -925,87 +912,85 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY in self.supported_features @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat + return MediaPlayerEntityFeature.PAUSE in self.supported_features @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features_compat + return MediaPlayerEntityFeature.STOP in self.supported_features @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features_compat + return MediaPlayerEntityFeature.SEEK in self.supported_features @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat - ) + return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat + return MediaPlayerEntityFeature.GROUPING in self.supported_features async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1034,7 +1019,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1052,7 +1037,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1095,7 +1080,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if ( source_list := self.source_list @@ -1301,7 +1286,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index ff246e420ce..1c9ba929b38 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -282,7 +282,7 @@ }, "clear_playlist": { "name": "Clear playlist", - "description": "Clears the playlist." + "description": "Removes all items from the playlist." }, "shuffle_set": { "name": "Shuffle", diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 08b3658c270..4defd47bc39 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -115,7 +115,6 @@ class MelCloudClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: MelCloudDevice) -> None: """Initialize the climate.""" diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py index 0ad663faa2a..ff68820d70f 100644 --- a/homeassistant/components/melissa/climate.py +++ b/homeassistant/components/melissa/climate.py @@ -65,7 +65,6 @@ class MelissaClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, serial_number, init_data): """Initialize the climate device.""" diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index e4ffbcba23e..ee4acaf05b0 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -121,7 +121,6 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: MillDataUpdateCoordinator, heater: mill.Heater @@ -218,7 +217,6 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index b50d21faf42..97ade53762b 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -121,7 +121,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): else: self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): - self._result = result.bits + self._result = [int(bit) for bit in result.bits] else: self._result = result.registers self._attr_is_on = bool(self._result[0] & 1) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index bcbaa0f32af..111c0458ef4 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -130,7 +130,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index 5d12fe37fd1..bed8ff102bb 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -38,8 +38,6 @@ async def async_setup_platform( class ModbusFan(BaseSwitch, FanEntity): """Class representing a Modbus fan.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__( self, hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any] ) -> None: diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7cba4692eb6..fc25a329c11 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.6.9"] + "requirements": ["pymodbus==3.7.4"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 18d91f8dd3b..efce44d7979 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -14,8 +14,8 @@ from pymodbus.client import ( AsyncModbusUdpClient, ) from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer +from pymodbus.framer import FramerType +from pymodbus.pdu import ModbusPDU import voluptuous as vol from homeassistant.const import ( @@ -265,14 +265,13 @@ class ModbusHub: "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], "retries": 3, - "retry_on_empty": True, } if self._config_type == SERIAL: # serial configuration if client_config[CONF_METHOD] == "ascii": - self._pb_params["framer"] = ModbusAsciiFramer + self._pb_params["framer"] = FramerType.ASCII else: - self._pb_params["framer"] = ModbusRtuFramer + self._pb_params["framer"] = FramerType.RTU self._pb_params.update( { "baudrate": client_config[CONF_BAUDRATE], @@ -285,9 +284,9 @@ class ModbusHub: # network configuration self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: - self._pb_params["framer"] = ModbusRtuFramer + self._pb_params["framer"] = FramerType.RTU else: - self._pb_params["framer"] = ModbusSocketFramer + self._pb_params["framer"] = FramerType.SOCKET if CONF_MSG_WAIT in client_config: self._msg_wait = client_config[CONF_MSG_WAIT] / 1000 @@ -370,12 +369,12 @@ class ModbusHub: async def low_level_pb_call( self, slave: int | None, address: int, value: int | list[int], use_call: str - ) -> ModbusResponse | None: + ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs = {"slave": slave} if slave else {} entry = self._pb_request[use_call] try: - result: ModbusResponse = await entry.func(address, value, **kwargs) + result: ModbusPDU = await entry.func(address, value, **kwargs) except ModbusException as exception_error: error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) @@ -403,7 +402,7 @@ class ModbusHub: address: int, value: int | list[int], use_call: str, - ) -> ModbusResponse | None: + ) -> ModbusPDU | None: """Convert async to sync pymodbus call.""" if self._config_delay: return None diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index a599c5b6dd6..988edcb60e5 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -78,7 +78,6 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): | FanEntityFeature.TURN_ON ) _attr_translation_key = "fan" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 33f17271800..7c24dad4469 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -47,7 +47,6 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = [PRESET_AUTO, PRESET_DAY, PRESET_NIGHT] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: Alpha2BaseCoordinator, heat_area_id: str) -> None: """Initialize Alpha2 ClimateEntity.""" diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index e6f795ecc91..5e5512a60bf 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -51,15 +51,6 @@ async def validate_input( DATA_SCHEMA_OPTIONS = vol.Schema( { - vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( - NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX) - ) - } -) - -DATA_SCHEMA_CONFIG = vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Required(CONF_INDOOR_TEMP): EntitySelector( EntitySelectorConfig( domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE @@ -75,6 +66,15 @@ DATA_SCHEMA_CONFIG = vol.Schema( domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE ) ), + vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( + NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX) + ), + } +) + +DATA_SCHEMA_CONFIG = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), } ).extend(DATA_SCHEMA_OPTIONS.schema) diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py index aa0f5ef7c90..740a0509a9e 100644 --- a/homeassistant/components/motionblinds_ble/sensor.py +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass import logging from math import ceil -from typing import Generic, TypeVar from motionblindsble.const import ( MotionBlindType, @@ -45,11 +44,9 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) -class MotionblindsBLESensorEntityDescription(SensorEntityDescription, Generic[_T]): +class MotionblindsBLESensorEntityDescription[_T](SensorEntityDescription): """Entity description of a sensor entity with initial_value attribute.""" initial_value: str | None = None @@ -110,7 +107,7 @@ async def async_setup_entry( async_add_entities(entities) -class MotionblindsBLESensorEntity(MotionblindsBLEEntity, SensorEntity, Generic[_T]): +class MotionblindsBLESensorEntity[_T](MotionblindsBLEEntity, SensorEntity): """Representation of a sensor entity.""" entity_description: MotionblindsBLESensorEntityDescription[_T] diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 1dcd0928434..0091d2370a4 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -119,7 +119,7 @@ MAX_PACKETS_TO_READ = 500 type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any -type SubscribePayloadType = str | bytes # Only bytes if encoding is None +type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None def publish( @@ -227,7 +227,7 @@ def async_subscribe_internal( translation_placeholders={"topic": topic}, ) from exc client = mqtt_data.client - if not client.connected and not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled", translation_key="mqtt_not_setup_cannot_subscribe", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 2419e3f32ac..e62303472ed 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -521,7 +521,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED _attr_target_temperature_low: float | None = None _attr_target_temperature_high: float | None = None - _enable_turn_on_off_backwards_compatibility = False @staticmethod def config_schema() -> VolSchemaType: diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 34d43ad87f3..0081246c705 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -18,6 +18,7 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -469,20 +470,32 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} fields: OrderedDict[Any, Any] = OrderedDict() validated_user_input: dict[str, Any] = {} + if is_reconfigure := (self.source == SOURCE_RECONFIGURE): + reconfigure_entry = self._get_reconfigure_entry() if await async_get_broker_settings( self, fields, - None, + reconfigure_entry.data if is_reconfigure else None, user_input, validated_user_input, errors, ): + if is_reconfigure: + update_password_from_user_input( + reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input + ) + can_connect = await self.hass.async_add_executor_job( try_connection, validated_user_input, ) if can_connect: + if is_reconfigure: + return self.async_update_reload_and_abort( + reconfigure_entry, + data=validated_user_input, + ) validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY return self.async_create_entry( title=validated_user_input[CONF_BROKER], @@ -495,6 +508,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="broker", data_schema=vol.Schema(fields), errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + return await self.async_step_broker() + async def async_step_hassio( self, discovery_info: HassioServiceInfo ) -> ConfigFlowResult: @@ -547,7 +566,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): def __init__(self) -> None: """Initialize MQTT options flow.""" - self.broker_config: dict[str, str | int] = {} + self.broker_config: dict[str, Any] = {} async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the MQTT options.""" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index c73e1975a68..fb047cc8d5e 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -137,7 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = { "extra_state_attributes", "force_update", "icon", - "name", + "friendly_name", "should_poll", "state", "supported_features", diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index b3c0f22789c..4d2e764a0d5 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -226,7 +226,6 @@ class MqttFan(MqttEntity, FanEntity): _optimistic_preset_mode: bool _payload: dict[str, Any] _speed_range: tuple[int, int] - _enable_turn_on_off_backwards_compatibility = False @staticmethod def config_schema() -> VolSchemaType: diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index de6a9d4c126..159a23d14d9 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -9,20 +9,25 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components.light import ( + _DEPRECATED_ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_MAX_MIREDS, + _DEPRECATED_ATTR_MIN_MIREDS, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_WHITE, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ENTITY_ID_FORMAT, ColorMode, LightEntity, @@ -112,12 +117,15 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( { ATTR_COLOR_MODE, ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + _DEPRECATED_ATTR_MAX_MIREDS.value, + ATTR_MIN_COLOR_TEMP_KELVIN, + _DEPRECATED_ATTR_MIN_MIREDS.value, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -240,7 +248,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _optimistic: bool _optimistic_brightness: bool _optimistic_color_mode: bool - _optimistic_color_temp: bool + _optimistic_color_temp_kelvin: bool _optimistic_effect: bool _optimistic_hs_color: bool _optimistic_rgb_color: bool @@ -255,8 +263,16 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds) - self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + if (max_mireds := config.get(CONF_MAX_MIREDS)) + else DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + if (min_mireds := config.get(CONF_MIN_MIREDS)) + else DEFAULT_MAX_KELVIN + ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) topic: dict[str, str | None] = { @@ -321,7 +337,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): and topic[CONF_RGB_STATE_TOPIC] is None ) ) - self._optimistic_color_temp = ( + self._optimistic_color_temp_kelvin = ( optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None ) self._optimistic_effect = optimistic or topic[CONF_EFFECT_STATE_TOPIC] is None @@ -472,10 +488,8 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def _converter( r: int, g: int, b: int, cw: int, ww: int ) -> tuple[int, int, int]: - min_kelvin = color_util.color_temperature_mired_to_kelvin(self.max_mireds) - max_kelvin = color_util.color_temperature_mired_to_kelvin(self.min_mireds) return color_util.color_rgbww_to_rgb( - r, g, b, cw, ww, min_kelvin, max_kelvin + r, g, b, cw, ww, self.min_color_temp_kelvin, self.max_color_temp_kelvin ) rgbww = self._rgbx_received( @@ -512,7 +526,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = int(payload) + self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + int(payload) + ) @callback def _effect_received(self, msg: ReceiveMessage) -> None: @@ -586,7 +602,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self.add_subscription( CONF_COLOR_TEMP_STATE_TOPIC, self._color_temp_received, - {"_attr_color_mode", "_attr_color_temp"}, + {"_attr_color_mode", "_attr_color_temp_kelvin"}, ) self.add_subscription( CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"} @@ -625,7 +641,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): restore_state(ATTR_RGBW_COLOR) restore_state(ATTR_RGBWW_COLOR) restore_state(ATTR_COLOR_MODE) - restore_state(ATTR_COLOR_TEMP) + restore_state(ATTR_COLOR_TEMP_KELVIN) restore_state(ATTR_EFFECT) restore_state(ATTR_HS_COLOR) restore_state(ATTR_XY_COLOR) @@ -797,14 +813,21 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) if ( - ATTR_COLOR_TEMP in kwargs + ATTR_COLOR_TEMP_KELVIN in kwargs and self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None ): ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE] - color_temp = ct_command_tpl(int(kwargs[ATTR_COLOR_TEMP]), None) + color_temp = ct_command_tpl( + color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ), + None, + ) await publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp) should_update |= set_optimistic( - ATTR_COLOR_TEMP, kwargs[ATTR_COLOR_TEMP], ColorMode.COLOR_TEMP + ATTR_COLOR_TEMP_KELVIN, + kwargs[ATTR_COLOR_TEMP_KELVIN], + ColorMode.COLOR_TEMP, ) if ( diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 89f338f6bab..f6efdd3281d 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -22,6 +22,8 @@ from homeassistant.components.light import ( ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, FLASH_LONG, @@ -273,8 +275,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds) - self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + if (max_mireds := config.get(CONF_MAX_MIREDS)) + else DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + if (min_mireds := config.get(CONF_MIN_MIREDS)) + else DEFAULT_MAX_KELVIN + ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) self._topic = { @@ -370,7 +380,11 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): return try: if color_mode == ColorMode.COLOR_TEMP: - self._attr_color_temp = int(values["color_temp"]) + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + values["color_temp"] + ) + ) self._attr_color_mode = ColorMode.COLOR_TEMP elif color_mode == ColorMode.HS: hue = float(values["color"]["h"]) @@ -469,12 +483,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): # Deprecated color handling try: if values["color_temp"] is None: - self._attr_color_temp = None + self._attr_color_temp_kelvin = None else: - self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type] + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + values["color_temp"] # type: ignore[arg-type] + ) + ) except KeyError: pass - except ValueError: + except (TypeError, ValueError): _LOGGER.warning( "Invalid color temp value '%s' received for entity %s", values["color_temp"], @@ -496,7 +514,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._state_received, { "_attr_brightness", - "_attr_color_temp", + "_attr_color_temp_kelvin", "_attr_effect", "_attr_hs_color", "_attr_is_on", @@ -522,8 +540,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = last_attributes.get( ATTR_COLOR_MODE, self.color_mode ) - self._attr_color_temp = last_attributes.get( - ATTR_COLOR_TEMP, self.color_temp + self._attr_color_temp_kelvin = last_attributes.get( + ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin ) self._attr_effect = last_attributes.get(ATTR_EFFECT, self.effect) self._attr_hs_color = last_attributes.get(ATTR_HS_COLOR, self.hs_color) @@ -623,7 +641,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): message["color"]["s"] = hs_color[1] if self._optimistic: - self._attr_color_temp = None + self._attr_color_temp_kelvin = None self._attr_hs_color = kwargs[ATTR_HS_COLOR] should_update = True @@ -690,12 +708,14 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = kwargs[ATTR_BRIGHTNESS] should_update = True - if ATTR_COLOR_TEMP in kwargs: - message["color_temp"] = int(kwargs[ATTR_COLOR_TEMP]) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + message["color_temp"] = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if self._optimistic: self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] + self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] self._attr_hs_color = None should_update = True diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index c4f9cad44c5..722bd864366 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -10,11 +10,13 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ENTITY_ID_FORMAT, ColorMode, LightEntity, @@ -126,8 +128,16 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds) - self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + if (max_mireds := config.get(CONF_MAX_MIREDS)) + else DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + if (min_mireds := config.get(CONF_MIN_MIREDS)) + else DEFAULT_MAX_KELVIN + ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) self._topics = { @@ -213,8 +223,10 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( msg.payload ) - self._attr_color_temp = ( - int(color_temp) if color_temp != "None" else None + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(int(color_temp)) + if color_temp != "None" + else None ) except ValueError: _LOGGER.warning("Invalid color temperature value received") @@ -256,7 +268,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): { "_attr_brightness", "_attr_color_mode", - "_attr_color_temp", + "_attr_color_temp_kelvin", "_attr_effect", "_attr_hs_color", "_attr_is_on", @@ -275,8 +287,10 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if last_state.attributes.get(ATTR_HS_COLOR): self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR) self._update_color_mode() - if last_state.attributes.get(ATTR_COLOR_TEMP): - self._attr_color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN): + self._attr_color_temp_kelvin = last_state.attributes.get( + ATTR_COLOR_TEMP_KELVIN + ) if last_state.attributes.get(ATTR_EFFECT): self._attr_effect = last_state.attributes.get(ATTR_EFFECT) @@ -295,11 +309,13 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if self._optimistic: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: - values["color_temp"] = int(kwargs[ATTR_COLOR_TEMP]) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + values["color_temp"] = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if self._optimistic: - self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] + self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] self._attr_hs_color = None self._update_color_mode() @@ -325,7 +341,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): values["sat"] = hs_color[1] if self._optimistic: - self._attr_color_temp = None + self._attr_color_temp_kelvin = None self._attr_hs_color = kwargs[ATTR_HS_COLOR] self._update_color_mode() diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml index d459f0420f1..f31d3e25d15 100644 --- a/homeassistant/components/mqtt/quality_scale.yaml +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -29,9 +29,12 @@ rules: MQTT broker, this happens during integration setup, and only one config entry is allowed. test-before-configure: done - test-before-setup: done + test-before-setup: + status: exempt + comment: > + We choose to early exit the entry as it can take some time for the client + to connect. Waiting for the client would increase the overall setup time. unique-config-entry: done - # Silver config-entry-unloading: done log-when-unavailable: done @@ -87,9 +90,9 @@ rules: This is not possible because the integrations generates entities based on a user supplied config or discovery. reconfiguration-flow: - status: exempt + status: done comment: > - This integration is reconfigured via options flow. + This integration can also be reconfigured via options flow. dynamic-devices: status: done comment: | diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 4d23007e51b..3b337c05d2a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -101,6 +101,7 @@ "addon_connection_failed": "Failed to connect to the {addon} add-on. Check the add-on status and try again later.", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { @@ -114,7 +115,7 @@ "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_inclusion": "The client certificate and private key must be configurered together" + "invalid_inclusion": "The client certificate and private key must be configured together" } }, "device_automation": { diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index c90174e8a01..0a54bcdb378 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -91,7 +91,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): _entity_id_format = switch.ENTITY_ID_FORMAT _optimistic: bool - _is_on_map: dict[str | bytes, bool | None] + _is_on_map: dict[str | bytes | bytearray, bool | None] _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index ac6dca3cbbc..743bfb363f3 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,20 +10,12 @@ import voluptuous as vol from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, - CONF_NAME, - STATE_IDLE, - STATE_PAUSED, -) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,13 +37,20 @@ BATTERY = "battery_level" FAN_SPEED = "fan_speed" STATE = "state" -POSSIBLE_STATES: dict[str, str] = { - STATE_IDLE: STATE_IDLE, - STATE_DOCKED: STATE_DOCKED, - STATE_ERROR: STATE_ERROR, - STATE_PAUSED: STATE_PAUSED, - STATE_RETURNING: STATE_RETURNING, - STATE_CLEANING: STATE_CLEANING, +STATE_IDLE = "idle" +STATE_DOCKED = "docked" +STATE_ERROR = "error" +STATE_PAUSED = "paused" +STATE_RETURNING = "returning" +STATE_CLEANING = "cleaning" + +POSSIBLE_STATES: dict[str, VacuumActivity] = { + STATE_IDLE: VacuumActivity.IDLE, + STATE_DOCKED: VacuumActivity.DOCKED, + STATE_ERROR: VacuumActivity.ERROR, + STATE_PAUSED: VacuumActivity.PAUSED, + STATE_RETURNING: VacuumActivity.RETURNING, + STATE_CLEANING: VacuumActivity.CLEANING, } CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES @@ -265,7 +264,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): if STATE in payload and ( (state := payload[STATE]) in POSSIBLE_STATES or state is None ): - self._attr_state = ( + self._attr_activity = ( POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None ) del payload[STATE] @@ -277,7 +276,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self.add_subscription( CONF_STATE_TOPIC, self._state_message_received, - {"_attr_battery_level", "_attr_fan_speed", "_attr_state"}, + {"_attr_battery_level", "_attr_fan_speed", "_attr_activity"}, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index fdf3a0c0c48..7d09bd5b888 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import Callable, Coroutine, Mapping from contextlib import suppress import functools import os -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate from music_assistant_models.enums import ( EventType, @@ -102,14 +102,14 @@ ATTR_AUTO_PLAY = "auto_play" def catch_musicassistant_error[_R, **P]( - func: Callable[..., Awaitable[_R]], -) -> Callable[..., Coroutine[Any, Any, _R | None]]: + func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]: """Check and log commands to players.""" @functools.wraps(func) async def wrapper( self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs - ) -> _R | None: + ) -> _R: """Catch Music Assistant errors and convert to Home Assistant error.""" try: return await func(self, *args, **kwargs) @@ -545,7 +545,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self, player: Player, queue: PlayerQueue | None ) -> None: """Update media attributes for the active queue item.""" - # pylint: disable=too-many-statements self._attr_media_artist = None self._attr_media_album_artist = None self._attr_media_album_name = None diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index ce15faa589c..23b7c47ebf3 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -72,7 +72,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST - _enable_turn_on_off_backwards_compatibility = False @property def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index d801f27817d..5ad114e973e 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations from http import HTTPStatus +import logging from aiohttp import ClientError, ClientResponseError +import jwt from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name from homeassistant.config_entries import ConfigEntry @@ -22,6 +24,8 @@ from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES from .coordinator import MyUplinkDataCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -51,13 +55,25 @@ async def async_setup_entry( await auth.async_get_access_token() except ClientResponseError as err: if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: - raise ConfigEntryAuthFailed from err - raise ConfigEntryNotReady from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="config_entry_auth_failed", + ) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err except ClientError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err if set(config_entry.data["token"]["scope"].split(" ")) != set(OAUTH2_SCOPES): - raise ConfigEntryAuthFailed("Incorrect OAuth2 scope") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="incorrect_oauth2_scope", + ) # Setup MyUplinkAPI and coordinator for data fetch api = MyUplinkAPI(auth) @@ -73,14 +89,16 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MyUplinkConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @callback def create_devices( - hass: HomeAssistant, config_entry: ConfigEntry, coordinator: MyUplinkDataCoordinator + hass: HomeAssistant, + config_entry: MyUplinkConfigEntry, + coordinator: MyUplinkDataCoordinator, ) -> None: """Update all devices.""" device_registry = dr.async_get(hass) @@ -109,3 +127,27 @@ async def async_remove_config_entry_device( return not device_entry.identifiers.intersection( (DOMAIN, device_id) for device_id in myuplink_data.data.devices ) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: MyUplinkConfigEntry +) -> bool: + """Migrate old entry.""" + + # Use sub(ject) from access_token as unique_id + if config_entry.version == 1 and config_entry.minor_version == 1: + token = jwt.decode( + config_entry.data["token"]["access_token"], + options={"verify_signature": False}, + ) + uid = token["sub"] + hass.config_entries.async_update_entry( + config_entry, unique_id=uid, minor_version=2 + ) + _LOGGER.info( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index 953859986d0..d903c7cbfae 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -155,7 +155,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity): self, coordinator: MyUplinkDataCoordinator, device_id: str, - entity_description: BinarySensorEntityDescription | None, + entity_description: BinarySensorEntityDescription, unique_id_suffix: str, ) -> None: """Initialize the binary_sensor.""" @@ -165,8 +165,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity): unique_id_suffix=unique_id_suffix, ) - if entity_description is not None: - self.entity_description = entity_description + self.entity_description = entity_description @property def is_on(self) -> bool: @@ -185,7 +184,7 @@ class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity): coordinator: MyUplinkDataCoordinator, system_id: str, device_id: str, - entity_description: BinarySensorEntityDescription | None, + entity_description: BinarySensorEntityDescription, unique_id_suffix: str, ) -> None: """Initialize the binary_sensor.""" @@ -196,8 +195,7 @@ class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity): unique_id_suffix=unique_id_suffix, ) - if entity_description is not None: - self.entity_description = entity_description + self.entity_description = entity_description @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py index 554347cfd19..cf0428f59ce 100644 --- a/homeassistant/components/myuplink/config_flow.py +++ b/homeassistant/components/myuplink/config_flow.py @@ -4,7 +4,13 @@ from collections.abc import Mapping import logging from typing import Any -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +import jwt + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -15,6 +21,8 @@ class OAuth2FlowHandler( ): """Config flow to handle myUplink OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -44,10 +52,30 @@ class OAuth2FlowHandler( return await self.async_step_user() + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated reconfiguration.""" + return await self.async_step_user() + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create or update the config entry.""" + + token = jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + ) + uid = token["sub"] + await self.async_set_unique_id(uid) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="account_mismatch") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=data + ) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index de5486d8dea..bd875d8a872 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -95,11 +95,17 @@ PARAMETER_ID_TO_EXCLUDE_F730 = ( ) PARAMETER_ID_TO_INCLUDE_SMO20 = ( + "40013", + "40033", "40940", + "44069", + "44071", + "44073", "47011", "47015", "47028", "47032", + "47398", "50004", ) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index 0e638a72715..8438d24194c 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["myuplink==0.6.0"] } diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index b05ab5d46c9..e1cbd393947 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator -from .const import F_SERIES +from .const import DOMAIN, F_SERIES from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -110,13 +110,16 @@ class MyUplinkNumber(MyUplinkEntity, NumberEntity): # Internal properties self.point_id = device_point.parameter_id self._attr_name = device_point.parameter_name + _scale = float(device_point.scale_value if device_point.scale_value else 1.0) self._attr_native_min_value = ( - device_point.raw["minValue"] if device_point.raw["minValue"] else -30000 - ) * float(device_point.raw.get("scaleValue", 1)) + device_point.min_value if device_point.min_value else -30000 + ) * _scale self._attr_native_max_value = ( - device_point.raw["maxValue"] if device_point.raw["maxValue"] else 30000 - ) * float(device_point.raw.get("scaleValue", 1)) - self._attr_step_value = device_point.raw.get("stepValue", 20) + device_point.max_value if device_point.max_value else 30000 + ) * _scale + self._attr_native_step = ( + device_point.step_value if device_point.step_value else 1.0 + ) * _scale if entity_description is not None: self.entity_description = entity_description @@ -134,7 +137,13 @@ class MyUplinkNumber(MyUplinkEntity, NumberEntity): ) except ClientError as err: raise HomeAssistantError( - f"Failed to set new value {value} for {self.point_id}/{self.entity_id}" + translation_domain=DOMAIN, + translation_key="set_number_error", + translation_placeholders={ + "entity": self.entity_id, + "point": self.point_id, + "value": str(value), + }, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/myuplink/quality_scale.yaml b/homeassistant/components/myuplink/quality_scale.yaml new file mode 100644 index 00000000000..be0780a206c --- /dev/null +++ b/homeassistant/components/myuplink/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters + docs-installation-parameters: + status: done + comment: Described in installation instructions + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: Handled by coordinator + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + Not possible to discover these devices. + discovery: + status: exempt + comment: | + Not possible to discover these devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: done + comment: | + Datapoint names are read from the API metadata and used as entity names in HA. + It is not feasible to use the API names as translation keys as they can change between + firmware and API upgrades and the number of appliance models and firmware releases are huge. + Entity names translations are therefore not implemented for the time being. + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + No repair-issues are raised. + stale-devices: + status: done + comment: | + There is no way for the integration to know if a device is gone temporarily or permanently. User is allowed to delete a stale device from GUI. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py index c0fb66602de..0074d1c75ff 100644 --- a/homeassistant/components/myuplink/select.py +++ b/homeassistant/components/myuplink/select.py @@ -5,13 +5,14 @@ from typing import cast from aiohttp import ClientError from myuplink import DevicePoint -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import SelectEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import DOMAIN from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity @@ -30,14 +31,12 @@ async def async_setup_entry( for point_id, device_point in point_data.items(): if skip_entity(device_point.category, device_point): continue - description = None - if find_matching_platform(device_point, description) == Platform.SELECT: + if find_matching_platform(device_point, None) == Platform.SELECT: entities.append( MyUplinkSelect( coordinator=coordinator, device_id=device_id, device_point=device_point, - entity_description=description, unique_id_suffix=point_id, ) ) @@ -53,7 +52,6 @@ class MyUplinkSelect(MyUplinkEntity, SelectEntity): coordinator: MyUplinkDataCoordinator, device_id: str, device_point: DevicePoint, - entity_description: SelectEntityDescription | None, unique_id_suffix: str, ) -> None: """Initialize the select.""" @@ -89,7 +87,13 @@ class MyUplinkSelect(MyUplinkEntity, SelectEntity): ) except ClientError as err: raise HomeAssistantError( - f"Failed to set new option {self.options_rev[option]} for {self.point_id}/{self.entity_id}" + translation_domain=DOMAIN, + translation_key="set_select_error", + translation_placeholders={ + "entity": self.entity_id, + "option": self.options_rev[option], + "point": self.point_id, + }, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 997c6fe54b6..939aa2f17c8 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -23,6 +23,8 @@ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "account_mismatch": "The used account does not match the original account", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -40,5 +42,25 @@ "name": "Status" } } + }, + "exceptions": { + "config_entry_auth_failed": { + "message": "Error while logging in to the API. Please check your credentials." + }, + "config_entry_not_ready": { + "message": "Error while loading the integration." + }, + "incorrect_oauth2_scope": { + "message": "Stored permissions are invalid. Please login again to update permissions." + }, + "set_number_error": { + "message": "Failed to set new value {value} for {point}/{entity}." + }, + "set_select_error": { + "message": "Failed to set new option {option} for {point}/{entity}." + }, + "set_switch_error": { + "message": "Failed to set state for {entity}." + } } } diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 75ba6bd7819..3addc7ce6a9 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator -from .const import F_SERIES +from .const import DOMAIN, F_SERIES from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -129,7 +129,11 @@ class MyUplinkDevicePointSwitch(MyUplinkEntity, SwitchEntity): ) except aiohttp.ClientError as err: raise HomeAssistantError( - f"Failed to set state for {self.entity_id}" + translation_domain=DOMAIN, + translation_key="set_switch_error", + translation_placeholders={ + "entity": self.entity_id, + }, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 19d817b9999..681053fa573 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -2,12 +2,11 @@ from __future__ import annotations -import math from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -17,10 +16,6 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) from . import NanoleafConfigEntry from .coordinator import NanoleafCoordinator @@ -51,10 +46,8 @@ class NanoleafLight(NanoleafEntity, LightEntity): """Initialize the Nanoleaf light.""" super().__init__(coordinator) self._attr_unique_id = self._nanoleaf.serial_no - self._attr_min_mireds = math.ceil( - 1000000 / self._nanoleaf.color_temperature_max - ) - self._attr_max_mireds = kelvin_to_mired(self._nanoleaf.color_temperature_min) + self._attr_max_color_temp_kelvin = self._nanoleaf.color_temperature_max + self._attr_min_color_temp_kelvin = self._nanoleaf.color_temperature_min @property def brightness(self) -> int: @@ -62,9 +55,9 @@ class NanoleafLight(NanoleafEntity, LightEntity): return int(self._nanoleaf.brightness * 2.55) @property - def color_temp(self) -> int: - """Return the current color temperature.""" - return kelvin_to_mired(self._nanoleaf.color_temperature) + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return self._nanoleaf.color_temperature @property def effect(self) -> str | None: @@ -106,7 +99,7 @@ class NanoleafLight(NanoleafEntity, LightEntity): """Instruct the light to turn on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) hs_color = kwargs.get(ATTR_HS_COLOR) - color_temp_mired = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) effect = kwargs.get(ATTR_EFFECT) transition = kwargs.get(ATTR_TRANSITION) @@ -120,10 +113,8 @@ class NanoleafLight(NanoleafEntity, LightEntity): hue, saturation = hs_color await self._nanoleaf.set_hue(int(hue)) await self._nanoleaf.set_saturation(int(saturation)) - elif color_temp_mired: - await self._nanoleaf.set_color_temperature( - mired_to_kelvin(color_temp_mired) - ) + elif color_temp_kelvin: + await self._nanoleaf.set_color_temperature(color_temp_kelvin) if transition: if brightness: # tune to the required brightness in n seconds await self._nanoleaf.set_brightness( diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 77ca5346b10..1a9285964a2 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -12,15 +12,12 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_STATUS, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE, STATE_IDLE, STATE_PAUSED +from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo @@ -169,23 +166,23 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): robot_alert = None if self._state["state"] == 1: if self._state["details"]["isCharging"]: - self._attr_state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._status_state = "Charging" elif ( self._state["details"]["isDocked"] and not self._state["details"]["isCharging"] ): - self._attr_state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._status_state = "Docked" else: - self._attr_state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self._status_state = "Stopped" if robot_alert is not None: self._status_state = robot_alert elif self._state["state"] == 2: if robot_alert is None: - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING self._status_state = ( f"{MODE.get(self._state['cleaning']['mode'])} " f"{ACTION.get(self._state['action'])}" @@ -200,10 +197,10 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): else: self._status_state = robot_alert elif self._state["state"] == 3: - self._attr_state = STATE_PAUSED + self._attr_activity = VacuumActivity.PAUSED self._status_state = "Paused" elif self._state["state"] == 4: - self._attr_state = STATE_ERROR + self._attr_activity = VacuumActivity.ERROR self._status_state = ERRORS.get(self._state["error"]) self._attr_battery_level = self._state["details"]["charge"] @@ -326,9 +323,9 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: - if self._attr_state == STATE_CLEANING: + if self._attr_activity == VacuumActivity.CLEANING: self.robot.pause_cleaning() - self._attr_state = STATE_RETURNING + self._attr_activity = VacuumActivity.RETURNING self.robot.send_to_base() except NeatoRobotException as ex: _LOGGER.error( @@ -380,7 +377,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): "Start cleaning zone '%s' with robot %s", zone, self.entity_id ) - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING try: self.robot.start_cleaning(mode, navigation, category, boundary_id) except NeatoRobotException as ex: diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 8a8a20c453b..0ef9d8d86f3 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", "iot_class": "cloud_polling", "quality_scale": "legacy", - "requirements": ["nsapi==3.0.5"] + "requirements": ["nsapi==3.1.2"] } diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 1e2727bfab7..d5ad28c2dfd 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -95,7 +95,6 @@ class ThermostatEntity(ClimateEntity): _attr_has_entity_name = True _attr_should_poll = False _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Device) -> None: """Initialize ThermostatEntity.""" diff --git a/homeassistant/components/nest/quality_scale.yaml b/homeassistant/components/nest/quality_scale.yaml new file mode 100644 index 00000000000..969ee66059d --- /dev/null +++ b/homeassistant/components/nest/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + config-flow: + status: todo + comment: Some fields are missing a data_description + brands: done + dependency-transparency: done + common-modules: + status: exempt + comment: The integration does not have a base entity or coordinator. + has-entity-name: done + action-setup: + status: exempt + comment: The integration does not register actions. + appropriate-polling: + status: exempt + comment: The integration does not poll. + test-before-configure: + status: todo + comment: | + The integration does a connection test in the configuration flow, however + it does not fail if the user has ipv6 misconfigured. + entity-event-setup: done + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: + status: todo + comment: | + The integration does tests on setup, however the most common issues + observed are related to ipv6 misconfigurations and the error messages + are not self explanatory and can be improved. + docs-high-level-description: done + config-flow-test-coverage: + status: todo + comment: | + The integration has full test coverage however it does not yet assert the specific contents of the + unique id of the created entry. Additional tests coverage for combinations of features like + `test_dhcp_discovery_with_creds` would also be useful. + Tests can be improved so that all end in either CREATE_ENTRY or ABORT. + docs-actions: done + runtime-data: done + + # Silver + log-when-unavailable: todo + config-entry-unloading: todo + reauthentication-flow: + status: todo + comment: | + Supports reauthentication, however can be improved to ensure the user does not change accounts + action-exceptions: todo + docs-installation-parameters: todo + integration-owner: todo + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: todo + entity-unavailable: todo + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: todo + icon-translations: todo + docs-known-limitations: todo + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 752dee5a952..02c955beac3 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -192,7 +192,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): _attr_name = None _away: bool | None = None _connected: bool | None = None - _enable_turn_on_off_backwards_compatibility = False _away_temperature: float | None = None _hg_temperature: float | None = None diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 8610882a453..71a8c548622 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -51,7 +51,6 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity): _attr_configuration_url = CONF_URL_CONTROL _attr_name = None device: NaModules.Fan - _enable_turn_on_off_backwards_compatibility = False def __init__(self, netatmo_device: NetatmoDevice) -> None: """Initialize of Netatmo fan.""" diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 199073298ab..8901a271de2 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["netdata"], "quality_scale": "legacy", - "requirements": ["netdata==1.1.0"] + "requirements": ["netdata==1.3.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index b77a4392ef4..f33349c56ce 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -70,7 +71,9 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) + netdata = NetdataData( + Netdata(host, port=port, timeout=20.0, httpx_client=get_async_client(hass)) + ) await netdata.async_update() if netdata.api.metrics is None: diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 9b22607d5a8..becd664756b 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -155,7 +155,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): """Provides Nexia Climate support.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index f89d6ec29a9..94db90e7f58 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -74,7 +74,6 @@ class NibeClimateEntity(CoordinatorEntity[CoilCoordinator], ClimateEntity): _attr_target_temperature_step = 0.5 _attr_max_temp = 35.0 _attr_min_temp = 5.0 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 407cdfcfd57..049ba905f04 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.13.0"] + "requirements": ["nibe==2.14.0"] } diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index 29c0d8233fe..07b20bbbf10 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -239,7 +239,6 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): ].type, # Device type is not sent in device state update, and it can't change, so we just reuse the existing one BarrierState( deviceId=raw_data["deviceId"], - desired=json.loads(raw_data["desired"]), reported=json.loads(raw_data["reported"]), connectionState=ConnectionState( connected=raw_data["connectionState"]["connected"], diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 817d7ef9bc9..1af23ec4d9b 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==0.3.10"] + "requirements": ["nice-go==1.0.0"] } diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index f1e2f4a78f0..a089209cde5 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -82,7 +82,6 @@ class NoboZone(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 # Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False - _enable_turn_on_off_backwards_compatibility = False def __init__(self, zone_id, hub: nobo, override_type) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index fa4e9ca2548..e6b36f7deee 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING @@ -69,23 +70,30 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): self.unsub = async_track_point_in_utc_time( self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) ) + data = await self.api_call() + if data: + self.async_set_updated_data(data) + + async def api_call(self, retry: int = 3) -> DeliveryPeriodData | None: + """Make api call to retrieve data with retry if failure.""" + data = None try: data = await self.client.async_get_delivery_period( dt_util.now(), Currency(self.config_entry.data[CONF_CURRENCY]), self.config_entry.data[CONF_AREAS], ) - except NordPoolEmptyResponseError as error: - LOGGER.debug("Empty response error: %s", error) - self.async_set_update_error(error) - return - except NordPoolResponseError as error: - LOGGER.debug("Response error: %s", error) - self.async_set_update_error(error) - return - except NordPoolError as error: + except ( + NordPoolEmptyResponseError, + NordPoolResponseError, + NordPoolError, + ) as error: LOGGER.debug("Connection error: %s", error) + if retry > 0: + next_run = (4 - retry) * 15 + LOGGER.debug("Wait %d seconds for next try", next_run) + await asyncio.sleep(next_run) + return await self.api_call(retry - 1) self.async_set_update_error(error) - return - self.async_set_updated_data(data) + return data diff --git a/homeassistant/components/nordpool/entity.py b/homeassistant/components/nordpool/entity.py index 32240aad12c..ec3264cd2e3 100644 --- a/homeassistant/components/nordpool/entity.py +++ b/homeassistant/components/nordpool/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,4 +29,5 @@ class NordpoolBaseEntity(CoordinatorEntity[NordPoolDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, area)}, name=f"Nord Pool {area}", + entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index bf093eb3ee9..215494e10a0 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pynordpool"], - "requirements": ["pynordpool==0.2.2"], + "quality_scale": "platinum", + "requirements": ["pynordpool==0.2.3"], "single_config_entry": true } diff --git a/homeassistant/components/nordpool/quality_scale.yaml b/homeassistant/components/nordpool/quality_scale.yaml new file mode 100644 index 00000000000..dada1115715 --- /dev/null +++ b/homeassistant/components/nordpool/quality_scale.yaml @@ -0,0 +1,95 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities doesn't subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + No actions. + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: exempt + comment: | + No discovery, cloud service + stale-devices: + status: exempt + comment: | + This integration devices (services) will be removed with config entry if needed. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: + status: exempt + comment: | + This integration has fixed devices. + discovery-update-info: + status: exempt + comment: | + No discovery + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: + status: exempt + comment: | + Only service, no device + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index e7e655a6657..47617cc8e42 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -27,7 +27,9 @@ from .entity import NordpoolBaseEntity PARALLEL_UPDATES = 0 -def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float]]: +def get_prices( + data: DeliveryPeriodData, +) -> dict[str, tuple[float | None, float, float | None]]: """Return previous, current and next prices. Output: {"SE3": (10.0, 10.5, 12.1)} @@ -39,6 +41,7 @@ def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float] previous_time = current_time - timedelta(hours=1) next_time = current_time + timedelta(hours=1) price_data = data.entries + LOGGER.debug("Price data: %s", price_data) for entry in price_data: if entry.start <= current_time <= entry.end: current_price_entries = entry.entry @@ -46,10 +49,20 @@ def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float] last_price_entries = entry.entry if entry.start <= next_time <= entry.end: next_price_entries = entry.entry + LOGGER.debug( + "Last price %s, current price %s, next price %s", + last_price_entries, + current_price_entries, + next_price_entries, + ) result = {} for area, price in current_price_entries.items(): - result[area] = (last_price_entries[area], price, next_price_entries[area]) + result[area] = ( + last_price_entries.get(area), + price, + next_price_entries.get(area), + ) LOGGER.debug("Prices: %s", result) return result @@ -90,7 +103,7 @@ class NordpoolDefaultSensorEntityDescription(SensorEntityDescription): class NordpoolPricesSensorEntityDescription(SensorEntityDescription): """Describes Nord Pool prices sensor entity.""" - value_fn: Callable[[tuple[float, float, float]], float | None] + value_fn: Callable[[tuple[float | None, float, float | None]], float | None] @dataclass(frozen=True, kw_only=True) @@ -136,13 +149,13 @@ PRICES_SENSOR_TYPES: tuple[NordpoolPricesSensorEntityDescription, ...] = ( NordpoolPricesSensorEntityDescription( key="last_price", translation_key="last_price", - value_fn=lambda data: data[0] / 1000, + value_fn=lambda data: data[0] / 1000 if data[0] else None, suggested_display_precision=2, ), NordpoolPricesSensorEntityDescription( key="next_price", translation_key="next_price", - value_fn=lambda data: data[2] / 1000, + value_fn=lambda data: data[2] / 1000 if data[2] else None, suggested_display_precision=2, ), ) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index db85827fc9b..8248c1b9b82 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -79,7 +79,6 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_preset_modes = PRESET_MODES - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 5a2f4c8675c..56466934e5f 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -163,7 +163,7 @@ class NumberDeviceClass(StrEnum): ENERGY = "energy" """Energy. - Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" @@ -172,7 +172,7 @@ class NumberDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ FREQUENCY = "frequency" @@ -467,7 +467,13 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, - NumberDeviceClass.POWER: {UnitOfPower.WATT, UnitOfPower.KILO_WATT}, + NumberDeviceClass.POWER: { + UnitOfPower.WATT, + UnitOfPower.KILO_WATT, + UnitOfPower.MEGA_WATT, + UnitOfPower.GIGA_WATT, + UnitOfPower.TERA_WATT, + }, NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), NumberDeviceClass.PRESSURE: set(UnitOfPressure), diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index cf16f1ba87e..4cecb9ff195 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -73,7 +73,6 @@ class ThermostatDevice(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat, name): """Initialize the device.""" diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py new file mode 100644 index 00000000000..4dc75cb574c --- /dev/null +++ b/homeassistant/components/ohme/__init__.py @@ -0,0 +1,65 @@ +"""Set up ohme integration.""" + +from dataclasses import dataclass + +from ohme import ApiException, AuthException, OhmeApiClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .coordinator import OhmeAdvancedSettingsCoordinator, OhmeChargeSessionCoordinator + +type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData] + + +@dataclass() +class OhmeRuntimeData: + """Dataclass to hold ohme coordinators.""" + + charge_session_coordinator: OhmeChargeSessionCoordinator + advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: + """Set up Ohme from a config entry.""" + + client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + + try: + await client.async_login() + + if not await client.async_update_device_info(): + raise ConfigEntryNotReady( + translation_key="device_info_failed", translation_domain=DOMAIN + ) + except AuthException as e: + raise ConfigEntryAuthFailed( + translation_key="auth_failed", translation_domain=DOMAIN + ) from e + except ApiException as e: + raise ConfigEntryNotReady( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + + coordinators = ( + OhmeChargeSessionCoordinator(hass, client), + OhmeAdvancedSettingsCoordinator(hass, client), + ) + + for coordinator in coordinators: + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = OhmeRuntimeData(*coordinators) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py new file mode 100644 index 00000000000..21792770bb4 --- /dev/null +++ b/homeassistant/components/ohme/button.py @@ -0,0 +1,77 @@ +"""Platform for button.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from ohme import ApiException, ChargerStatus, OhmeApiClient + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription): + """Class describing Ohme button entities.""" + + press_fn: Callable[[OhmeApiClient], Awaitable[None]] + available_fn: Callable[[OhmeApiClient], bool] + + +BUTTON_DESCRIPTIONS = [ + OhmeButtonDescription( + key="approve", + translation_key="approve", + press_fn=lambda client: client.async_approve_charge(), + is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"), + available_fn=lambda client: client.status is ChargerStatus.PENDING_APPROVAL, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons.""" + coordinator = config_entry.runtime_data.charge_session_coordinator + + async_add_entities( + OhmeButton(coordinator, description) + for description in BUTTON_DESCRIPTIONS + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeButton(OhmeEntity, ButtonEntity): + """Generic button for Ohme.""" + + entity_description: OhmeButtonDescription + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_fn(self.coordinator.client) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() + + @property + def available(self) -> bool: + """Is entity available.""" + + return super().available and self.entity_description.available_fn( + self.coordinator.client + ) diff --git a/homeassistant/components/ohme/config_flow.py b/homeassistant/components/ohme/config_flow.py new file mode 100644 index 00000000000..748ea558983 --- /dev/null +++ b/homeassistant/components/ohme/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for ohme integration.""" + +from collections.abc import Mapping +from typing import Any + +from ohme import ApiException, AuthException, OhmeApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } +) + +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } +) + + +class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """First config step.""" + + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + + errors = await self._validate_account( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + errors = await self._validate_account( + reauth_entry.data[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, + errors=errors, + ) + + async def _validate_account(self, email: str, password: str) -> dict[str, str]: + """Validate Ohme account and return dict of errors.""" + errors: dict[str, str] = {} + client = OhmeApiClient( + email, + password, + ) + try: + await client.async_login() + except AuthException: + errors["base"] = "invalid_auth" + except ApiException: + errors["base"] = "unknown" + + return errors diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py new file mode 100644 index 00000000000..b44262ad509 --- /dev/null +++ b/homeassistant/components/ohme/const.py @@ -0,0 +1,6 @@ +"""Component constants.""" + +from homeassistant.const import Platform + +DOMAIN = "ohme" +PLATFORMS = [Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py new file mode 100644 index 00000000000..5de59b3d4b2 --- /dev/null +++ b/homeassistant/components/ohme/coordinator.py @@ -0,0 +1,68 @@ +"""Ohme coordinators.""" + +from abc import abstractmethod +from datetime import timedelta +import logging + +from ohme import ApiException, OhmeApiClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OhmeBaseCoordinator(DataUpdateCoordinator[None]): + """Base for all Ohme coordinators.""" + + client: OhmeApiClient + _default_update_interval: timedelta | None = timedelta(minutes=1) + coordinator_name: str = "" + + def __init__(self, hass: HomeAssistant, client: OhmeApiClient) -> None: + """Initialise coordinator.""" + super().__init__( + hass, + _LOGGER, + name="", + update_interval=self._default_update_interval, + ) + + self.name = f"Ohme {self.coordinator_name}" + self.client = client + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self._internal_update_data() + except ApiException as e: + raise UpdateFailed( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + + @abstractmethod + async def _internal_update_data(self) -> None: + """Update coordinator data.""" + + +class OhmeChargeSessionCoordinator(OhmeBaseCoordinator): + """Coordinator to pull all updates from the API.""" + + coordinator_name = "Charge Sessions" + _default_update_interval = timedelta(seconds=30) + + async def _internal_update_data(self): + """Fetch data from API endpoint.""" + await self.client.async_get_charge_session() + + +class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator): + """Coordinator to pull settings and charger state from the API.""" + + coordinator_name = "Advanced Settings" + + async def _internal_update_data(self): + """Fetch data from API endpoint.""" + await self.client.async_get_advanced_settings() diff --git a/homeassistant/components/ohme/entity.py b/homeassistant/components/ohme/entity.py new file mode 100644 index 00000000000..6a7d0ea16e4 --- /dev/null +++ b/homeassistant/components/ohme/entity.py @@ -0,0 +1,54 @@ +"""Base class for entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from ohme import OhmeApiClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OhmeBaseCoordinator + + +@dataclass(frozen=True) +class OhmeEntityDescription(EntityDescription): + """Class describing Ohme entities.""" + + is_supported_fn: Callable[[OhmeApiClient], bool] = lambda _: True + + +class OhmeEntity(CoordinatorEntity[OhmeBaseCoordinator]): + """Base class for all Ohme entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OhmeBaseCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + client = coordinator.client + self._attr_unique_id = f"{client.serial}_{entity_description.key}" + + device_info = client.device_info + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, client.serial)}, + name=device_info["name"], + manufacturer="Ohme", + model=device_info["model"], + sw_version=device_info["sw_version"], + serial_number=client.serial, + ) + + @property + def available(self) -> bool: + """Return if charger reporting as online.""" + return super().available and self.coordinator.client.available diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json new file mode 100644 index 00000000000..d5bf3fa1187 --- /dev/null +++ b/homeassistant/components/ohme/icons.json @@ -0,0 +1,23 @@ +{ + "entity": { + "button": { + "approve": { + "default": "mdi:check-decagram" + } + }, + "sensor": { + "status": { + "default": "mdi:car", + "state": { + "unplugged": "mdi:power-plug-off", + "plugged_in": "mdi:power-plug", + "charging": "mdi:battery-charging-100", + "pending_approval": "mdi:alert-decagram" + } + }, + "ct_current": { + "default": "mdi:gauge" + } + } + } +} diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json new file mode 100644 index 00000000000..c9e1ccf9ac2 --- /dev/null +++ b/homeassistant/components/ohme/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ohme", + "name": "Ohme", + "codeowners": ["@dan-r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ohme/", + "integration_type": "device", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["ohme==1.1.1"] +} diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml new file mode 100644 index 00000000000..7fc2f55e2f9 --- /dev/null +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration has no custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration has no custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration has no explicit subscriptions to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + All supported devices are cloud connected over mobile data. Discovery is not possible. + discovery-update-info: + status: exempt + comment: | + All supported devices are cloud connected over mobile data. Discovery is not possible. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration currently has no repairs. + stale-devices: todo + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py new file mode 100644 index 00000000000..6d111cf7af6 --- /dev/null +++ b/homeassistant/components/ohme/sensor.py @@ -0,0 +1,106 @@ +"""Platform for sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from ohme import ChargerStatus, OhmeApiClient + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription): + """Class describing Ohme sensor entities.""" + + value_fn: Callable[[OhmeApiClient], str | int | float] + + +SENSOR_CHARGE_SESSION = [ + OhmeSensorDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=[e.value for e in ChargerStatus], + value_fn=lambda client: client.status.value, + ), + OhmeSensorDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda client: client.power.amps, + ), + OhmeSensorDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=1, + value_fn=lambda client: client.power.watts, + ), + OhmeSensorDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda client: client.energy, + ), +] + +SENSOR_ADVANCED_SETTINGS = [ + OhmeSensorDescription( + key="ct_current", + translation_key="ct_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda client: client.power.ct_amps, + is_supported_fn=lambda client: client.ct_connected, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + coordinators = config_entry.runtime_data + coordinator_map = [ + (SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator), + (SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator), + ] + + async_add_entities( + OhmeSensor(coordinator, description) + for entities, coordinator in coordinator_map + for description in entities + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeSensor(OhmeEntity, SensorEntity): + """Generic sensor for Ohme.""" + + entity_description: OhmeSensorDescription + + @property + def native_value(self) -> str | int | float: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json new file mode 100644 index 00000000000..125babc1901 --- /dev/null +++ b/homeassistant/components/ohme/strings.json @@ -0,0 +1,67 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure your Ohme account. If you signed up to Ohme with a third party account like Google, please reset your password via Ohme before configuring this integration.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Enter the email address associated with your Ohme account.", + "password": "Enter the password for your Ohme account" + } + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the password for your Ohme account" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "button": { + "approve": { + "name": "Approve charge" + } + }, + "sensor": { + "status": { + "name": "Status", + "state": { + "unplugged": "Unplugged", + "plugged_in": "Plugged in", + "charging": "Charging", + "pending_approval": "Pending approval" + } + }, + "ct_current": { + "name": "CT current" + } + } + }, + "exceptions": { + "auth_failed": { + "message": "Unable to login to Ohme" + }, + "device_info_failed": { + "message": "Unable to get Ohme device information" + }, + "api_failed": { + "message": "Error communicating with Ohme API" + } + } +} diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index c9030cab8ea..2dca53af1cf 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -233,7 +233,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "1D": tuple( OneWireSensorEntityDescription( key=f"counter.{device_key}", - native_unit_of_measurement="count", read_mode=READ_MODE_INT, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="counter_id", diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 41e36a7f237..24d63c0d9e4 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -111,6 +111,7 @@ AUDIO_INFORMATION_MAPPING = [ "precision_quartz_lock_system", "auto_phase_control_delay", "auto_phase_control_phase", + "upmix_mode", ] VIDEO_INFORMATION_MAPPING = [ @@ -123,6 +124,7 @@ VIDEO_INFORMATION_MAPPING = [ "output_color_schema", "output_color_depth", "picture_mode", + "input_hdr", ] ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml new file mode 100644 index 00000000000..cdcf88e72d7 --- /dev/null +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: | + This integration uses a push API. No polling required. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: + status: todo + comment: | + Coverage is 100%, but the tests need to be improved. + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: done + comment: | + Currently we store created entities in hass.data. That should be removed in the future. + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: done + entity-device-class: todo + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration is not making any HTTP requests. + strict-typing: + status: todo + comment: | + The library is not fully typed yet. diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index 1b0eadcc45e..95ca1199a36 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -10,18 +10,28 @@ "manual": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of the receiver." } }, "eiscp_discovery": { "data": { "device": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "device": "Select the receiver to configure." } }, "configure_receiver": { "description": "Configure {name}", "data": { - "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume", - "input_sources": "List of input sources supported by the receiver" + "volume_resolution": "Volume resolution", + "input_sources": "Input sources" + }, + "data_description": { + "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", + "input_sources": "List of input sources supported by the receiver." } } }, @@ -43,6 +53,9 @@ "init": { "data": { "max_volume": "Maximum volume limit (%)" + }, + "data_description": { + "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value." } } } diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index d03073dcfd3..02ef16b6787 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.12", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 57bd8a974db..d7bbaa4fb3f 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -370,6 +370,63 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: return None +@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") +@PARSERS.register("tns1:RuleEngine/PeopleDetector/People") +async def async_parse_tplink_detector(uid: str, msg) -> Event | None: + """Handle parsing tplink smart event messages. + + Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent + Topic: tns1:RuleEngine/PeopleDetector/People + """ + video_source = "" + video_analytics = "" + rule = "" + topic = "" + vehicle = False + person = False + enabled = False + try: + 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: + if item.Name == "IsVehicle": + vehicle = True + enabled = item.Value == "true" + if item.Name == "IsPeople": + person = True + enabled = item.Value == "true" + except (AttributeError, KeyError): + return None + + if vehicle: + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Vehicle Detection", + "binary_sensor", + "motion", + None, + enabled, + ) + if person: + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Person Detection", + "binary_sensor", + "motion", + None, + enabled, + ) + + return None + + @PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") async def async_parse_person_detector(uid: str, msg) -> Event | None: """Handle parsing event message. diff --git a/homeassistant/components/open_meteo/manifest.json b/homeassistant/components/open_meteo/manifest.json index abdb59a48d0..a2f2a724ad5 100644 --- a/homeassistant/components/open_meteo/manifest.json +++ b/homeassistant/components/open_meteo/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/open_meteo", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["open-meteo==0.3.1"] + "requirements": ["open-meteo==0.3.2"] } diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index e93a76fe7b7..e8aa99f7325 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -85,7 +85,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): _away_mode_b: int | None = None _away_state_a = False _away_state_b = False - _enable_turn_on_off_backwards_compatibility = False + _target_temperature: float | None = None _new_target_temperature: float | None = None entity_description: OpenThermClimateEntityDescription diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index a4aa98bbf69..da2993d1996 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, @@ -20,10 +20,6 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) _LOGGER = logging.getLogger(__name__) @@ -58,6 +54,8 @@ class OppleLight(LightEntity): _attr_color_mode = ColorMode.COLOR_TEMP _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + _attr_min_color_temp_kelvin = 3000 # 333 Mireds + _attr_max_color_temp_kelvin = 5700 # 175 Mireds def __init__(self, name, host): """Initialize an Opple light.""" @@ -67,7 +65,6 @@ class OppleLight(LightEntity): self._name = name self._is_on = None self._brightness = None - self._color_temp = None @property def available(self) -> bool: @@ -94,21 +91,6 @@ class OppleLight(LightEntity): """Return the brightness of the light.""" return self._brightness - @property - def color_temp(self): - """Return the color temperature of this light.""" - return kelvin_to_mired(self._color_temp) - - @property - def min_mireds(self): - """Return minimum supported color temperature.""" - return 175 - - @property - def max_mireds(self): - """Return maximum supported color temperature.""" - return 333 - def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on light %s %s", self._device.ip, kwargs) @@ -118,9 +100,11 @@ class OppleLight(LightEntity): if ATTR_BRIGHTNESS in kwargs and self.brightness != kwargs[ATTR_BRIGHTNESS]: self._device.brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs and self.color_temp != kwargs[ATTR_COLOR_TEMP]: - color_temp = mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - self._device.color_temperature = color_temp + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and self.color_temp_kelvin != kwargs[ATTR_COLOR_TEMP_KELVIN] + ): + self._device.color_temperature = kwargs[ATTR_COLOR_TEMP_KELVIN] def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -136,7 +120,7 @@ class OppleLight(LightEntity): prev_available == self.available and self._is_on == self._device.power_on and self._brightness == self._device.brightness - and self._color_temp == self._device.color_temperature + and self._attr_color_temp_kelvin == self._device.color_temperature ): return @@ -146,7 +130,7 @@ class OppleLight(LightEntity): self._is_on = self._device.power_on self._brightness = self._device.brightness - self._color_temp = self._device.color_temperature + self._attr_color_temp_kelvin = self._device.color_temperature if not self.is_on: _LOGGER.debug("Update light %s success: power off", self._device.ip) @@ -155,5 +139,5 @@ class OppleLight(LightEntity): "Update light %s success: power on brightness %s color temperature %s", self._device.ip, self._brightness, - self._color_temp, + self._attr_color_temp_kelvin, ) diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 0254c478b42..6ddd392af7b 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -191,10 +191,7 @@ class Luminary(LightEntity): self._effect_list = [] self._is_on = False self._available = True - self._min_mireds = None - self._max_mireds = None self._brightness = None - self._color_temp = None self._rgb_color = None self._device_attributes = None @@ -256,11 +253,6 @@ class Luminary(LightEntity): """Return last hs color value set.""" return color_util.color_RGB_to_hs(*self._rgb_color) - @property - def color_temp(self): - """Return the color temperature.""" - return self._color_temp - @property def brightness(self): """Return brightness of the luminary (0..255).""" @@ -276,16 +268,6 @@ class Luminary(LightEntity): """List of supported effects.""" return self._effect_list - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self._min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self._max_mireds - @property def unique_id(self): """Return a unique ID.""" @@ -326,12 +308,10 @@ class Luminary(LightEntity): self._rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self._luminary.set_rgb(*self._rgb_color, transition) - if ATTR_COLOR_TEMP in kwargs: - self._color_temp = kwargs[ATTR_COLOR_TEMP] - self._luminary.set_temperature( - int(color_util.color_temperature_mired_to_kelvin(self._color_temp)), - transition, - ) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + self._attr_color_temp_kelvin = color_temp_kelvin + self._luminary.set_temperature(color_temp_kelvin, transition) self._is_on = True if ATTR_BRIGHTNESS in kwargs: @@ -362,10 +342,10 @@ class Luminary(LightEntity): self._attr_supported_features = self._get_supported_features() self._effect_list = self._get_effect_list() if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._min_mireds = color_util.color_temperature_kelvin_to_mired( + self._attr_max_color_temp_kelvin = ( self._luminary.max_temp() or DEFAULT_KELVIN ) - self._max_mireds = color_util.color_temperature_kelvin_to_mired( + self._attr_min_color_temp_kelvin = ( self._luminary.min_temp() or DEFAULT_KELVIN ) if len(self._attr_supported_color_modes) == 1: @@ -380,9 +360,7 @@ class Luminary(LightEntity): self._brightness = int(self._luminary.lum() * 2.55) if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._color_temp = color_util.color_temperature_kelvin_to_mired( - self._luminary.temp() or DEFAULT_KELVIN - ) + self._attr_color_temp_kelvin = self._luminary.temp() or DEFAULT_KELVIN if ColorMode.HS in self._attr_supported_color_modes: self._rgb_color = self._luminary.rgb() diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py index ce9857f9d8c..059e64ef55d 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py @@ -54,7 +54,6 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 64a7dc1e645..93c7d03293b 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -76,7 +76,6 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index e49fc4358e9..92bd6ceae82 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py @@ -46,7 +46,6 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py index f1d96b5687b..bb84fa76f22 100644 --- a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py @@ -55,7 +55,6 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py index 1cd13205b13..800516e4bda 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py @@ -41,7 +41,6 @@ class AtlanticPassAPCHeatPumpMainComponent(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py index 3da2ccc922b..3df31fb44fc 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py @@ -92,7 +92,6 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py index 7fbab821b8d..7846b058619 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py @@ -31,7 +31,6 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py index efdae2165a9..41da90f1ce8 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -91,7 +91,6 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py index b31ecf91ec0..f60cbbeca2b 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py @@ -95,7 +95,6 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py index acc761664ec..5ca17f9b6b1 100644 --- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py @@ -82,7 +82,6 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/somfy_thermostat.py b/homeassistant/components/overkiz/climate/somfy_thermostat.py index 829a3bad03b..66a04af4e7a 100644 --- a/homeassistant/components/overkiz/climate/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate/somfy_thermostat.py @@ -65,7 +65,6 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 diff --git a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py index e2165e8b6c6..54c00b33167 100644 --- a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py @@ -56,7 +56,6 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index 4bea4434496..f20b3d11261 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py index 4cb8b1f14a6..b2e27b2a6fd 100644 --- a/homeassistant/components/palazzetti/const.py +++ b/homeassistant/components/palazzetti/const.py @@ -4,6 +4,8 @@ from datetime import timedelta import logging from typing import Final +from homeassistant.helpers.typing import StateType + DOMAIN: Final = "palazzetti" PALAZZETTI: Final = "Palazzetti" LOGGER = logging.getLogger(__package__) @@ -17,3 +19,53 @@ FAN_SILENT: Final = "silent" FAN_HIGH: Final = "high" FAN_AUTO: Final = "auto" FAN_MODES: Final = [FAN_SILENT, "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] + +STATUS_TO_HA: Final[dict[StateType, str]] = { + 0: "off", + 1: "off_timer", + 2: "test_fire", + 3: "heatup", + 4: "fueling", + 5: "ign_test", + 6: "burning", + 7: "burning_mod", + 8: "unknown", + 9: "cool_fluid", + 10: "fire_stop", + 11: "clean_fire", + 12: "cooling", + 50: "cleanup", + 51: "ecomode", + 241: "chimney_alarm", + 243: "grate_error", + 244: "pellet_water_error", + 245: "t05_error", + 247: "hatch_door_open", + 248: "pressure_error", + 249: "main_probe_failure", + 250: "flue_probe_failure", + 252: "exhaust_temp_high", + 253: "pellet_finished", + 501: "off", + 502: "fueling", + 503: "ign_test", + 504: "burning", + 505: "firewood_finished", + 506: "cooling", + 507: "clean_fire", + 1000: "general_error", + 1001: "general_error", + 1239: "door_open", + 1240: "temp_too_high", + 1241: "cleaning_warning", + 1243: "fuel_error", + 1244: "pellet_water_error", + 1245: "t05_error", + 1247: "hatch_door_open", + 1248: "pressure_error", + 1249: "main_probe_failure", + 1250: "flue_probe_failure", + 1252: "exhaust_temp_high", + 1253: "pellet_finished", + 1508: "general_error", +} diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py new file mode 100644 index 00000000000..06114bfef54 --- /dev/null +++ b/homeassistant/components/palazzetti/number.py @@ -0,0 +1,66 @@ +"""Number platform for Palazzetti settings.""" + +from __future__ import annotations + +from pypalazzetti.exceptions import CommunicationError, ValidationError + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PalazzettiConfigEntry +from .const import DOMAIN +from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Palazzetti number platform.""" + async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)]) + + +class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): + """Representation of Palazzetti number entity for Combustion power.""" + + _attr_translation_key = "combustion_power" + _attr_device_class = NumberDeviceClass.POWER_FACTOR + _attr_native_min_value = 1 + _attr_native_max_value = 5 + _attr_native_step = 1 + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + ) -> None: + """Initialize the Palazzetti number entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-combustion_power" + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.coordinator.client.power_mode + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + try: + await self.coordinator.client.set_power_mode(int(value)) + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + except ValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_combustion_power", + translation_placeholders={ + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index ead2b236b17..11462201f4e 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import PalazzettiConfigEntry +from .const import STATUS_TO_HA from .coordinator import PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity @@ -23,10 +24,19 @@ class PropertySensorEntityDescription(SensorEntityDescription): """Describes a Palazzetti sensor entity that is read from a `PalazzettiClient` property.""" client_property: str + property_map: dict[StateType, str] | None = None presence_flag: None | str = None PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [ + PropertySensorEntityDescription( + key="status", + device_class=SensorDeviceClass.ENUM, + translation_key="status", + client_property="status", + property_map=STATUS_TO_HA, + options=list(STATUS_TO_HA.values()), + ), PropertySensorEntityDescription( key="pellet_quantity", device_class=SensorDeviceClass.WEIGHT, @@ -103,4 +113,11 @@ class PalazzettiSensor(PalazzettiEntity, SensorEntity): def native_value(self) -> StateType: """Return the state value of the sensor.""" - return getattr(self.coordinator.client, self.entity_description.client_property) + raw_value = getattr( + self.coordinator.client, self.entity_description.client_property + ) + + if self.entity_description.property_map: + return self.entity_description.property_map[raw_value] + + return raw_value diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 435ec0aab85..ad7bc498bd1 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -30,6 +30,9 @@ "invalid_target_temperature": { "message": "Target temperature {value} is invalid." }, + "invalid_combustion_power": { + "message": "Combustion power {value} is invalid." + }, "cannot_connect": { "message": "Could not connect to the device." } @@ -48,7 +51,48 @@ } } }, + "number": { + "combustion_power": { + "name": "Combustion power" + } + }, "sensor": { + "status": { + "name": "Status", + "state": { + "off": "Off", + "off_timer": "Timer-regulated switch off", + "test_fire": "Ignition test", + "heatup": "Pellet feed", + "fueling": "Ignition", + "ign_test": "Fuel check", + "burning": "Operating", + "burning_mod": "Operating - Modulating", + "unknown": "Unknown", + "cool_fluid": "Stand-by", + "fire_stop": "Switch off", + "clean_fire": "Burn pot cleaning", + "cooling": "Cooling in progress", + "cleanup": "Final cleaning", + "ecomode": "Ecomode", + "chimney_alarm": "Chimney alarm", + "grate_error": "Grate error", + "pellet_water_error": "Pellet probe or return water error", + "t05_error": "T05 error disconnected or faulty probe", + "hatch_door_open": "Feed hatch or door open", + "pressure_error": "Safety pressure switch error", + "main_probe_failure": "Main probe failure", + "flue_probe_failure": "Flue gas probe failure", + "exhaust_temp_high": "Too high exhaust gas temperature", + "pellet_finished": "Pellets finished or ignition failed", + "firewood_finished": "Firewood finished", + "general_error": "General error", + "door_open": "Door open", + "temp_too_high": "Temperature too high", + "cleaning_warning": "Cleaning warning", + "fuel_error": "Fuel error" + } + }, "pellet_quantity": { "name": "Pellet quantity" }, diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index f422d4facf3..539fa243d6c 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -23,6 +23,9 @@ from .entity import PlugwiseEntity SEVERITIES = ["other", "info", "warning", "error"] +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index 078d31bea12..8a05ede3496 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -13,6 +13,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 06b8171a528..3caed1e7bc2 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -24,6 +24,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -63,7 +65,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False _previous_mode: str = "heating" @@ -187,27 +188,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): """Return the current running hvac operation if supported.""" # Keep track of the previous action-mode self._previous_action_mode(self.coordinator) - - # Adam provides the hvac_action for each thermostat - if self._gateway["smile_name"] == "Adam": - if (control_state := self.device.get("control_state")) == "cooling": - return HVACAction.COOLING - if control_state == "heating": - return HVACAction.HEATING - if control_state == "preheating": - return HVACAction.PREHEATING - if control_state == "off": - return HVACAction.IDLE - - return HVACAction.IDLE - - # Anna - heater: str = self._gateway["heater_id"] - heater_data = self._devices[heater] - if heater_data["binary_sensors"]["heating_state"]: - return HVACAction.HEATING - if heater_data["binary_sensors"].get("cooling_state", False): - return HVACAction.COOLING + if (action := self.device.get("control_state")) is not None: + return HVACAction(action) return HVACAction.IDLE @@ -227,12 +209,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if ATTR_TARGET_TEMP_LOW in kwargs: data["setpoint_low"] = kwargs.get(ATTR_TARGET_TEMP_LOW) - for temperature in data.values(): - if temperature is None or not ( - self._attr_min_temp <= temperature <= self._attr_max_temp - ): - raise ValueError("Invalid temperature change requested") - if mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(mode) @@ -242,7 +218,15 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the hvac mode.""" if hvac_mode not in self.hvac_modes: - raise HomeAssistantError("Unsupported hvac_mode") + hvac_modes = ", ".join(self.hvac_modes) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_hvac_mode_requested", + translation_placeholders={ + "hvac_mode": hvac_mode, + "hvac_modes": hvac_modes, + }, + ) if hvac_mode == self.hvac_mode: return diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 6ce6855e7d6..7ac0cc21c51 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -68,26 +68,37 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): async def _async_update_data(self) -> PlugwiseData: """Fetch data from Plugwise.""" - data = PlugwiseData(devices={}, gateway={}) try: if not self._connected: await self._connect() data = await self.api.async_update() except ConnectionFailedError as err: - raise UpdateFailed("Failed to connect") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="failed_to_connect", + ) from err except InvalidAuthentication as err: - raise ConfigEntryError("Authentication failed") from err + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except (InvalidXMLError, ResponseError) as err: raise UpdateFailed( - "Invalid XML data, or error indication received from the Plugwise Adam/Smile/Stretch" + translation_domain=DOMAIN, + translation_key="invalid_xml_data", ) from err except PlugwiseError as err: - raise UpdateFailed("Data incomplete or missing") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_incomplete_or_missing", + ) from err except UnsupportedDeviceError as err: - raise ConfigEntryError("Device with unsupported firmware") from err - else: - self._async_add_remove_devices(data, self.config_entry) + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unsupported_firmware", + ) from err + self._async_add_remove_devices(data, self.config_entry) return data def _async_add_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 7b28bf78342..3f63abaff43 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -77,8 +77,3 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): def device(self) -> GwEntityData: """Return data for this device.""" return self.coordinator.data.devices[self._dev_id] - - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - self._handle_coordinator_update() - await super().async_added_to_hass() diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index df35777ac54..80f5be974e1 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.6.1"], + "requirements": ["plugwise==1.6.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 833ea3ec761..1d0b1382c24 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -20,6 +20,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PlugwiseNumberEntityDescription(NumberEntityDescription): diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml index 0881e79c1c0..ce0788c44f7 100644 --- a/homeassistant/components/plugwise/quality_scale.yaml +++ b/homeassistant/components/plugwise/quality_scale.yaml @@ -2,60 +2,42 @@ rules: ## Bronze config-flow: done test-before-configure: done - unique-config-entry: - status: todo - comment: Add tests preventing second entry for same device - config-flow-test-coverage: - status: todo - comment: Cover test_form and zeroconf - runtime-data: - status: todo - comment: Clean up test_init for testing internals + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done test-before-setup: done - appropriate-polling: - status: todo - comment: Clean up coordinator (L71) check for mypy happiness + appropriate-polling: done entity-unique-id: done has-entity-name: done entity-event-setup: done dependency-transparency: done action-setup: - status: todo - comment: Check if we have these, otherwise exempt - common-modules: - status: todo - comment: Verify entity for async_added_to_hass usage (discard?) + status: exempt + comment: Plugwise integration has no custom actions + common-modules: done docs-high-level-description: status: todo - comment: Rewrite top section + comment: Rewrite top section, docs PR prepared waiting for 36087 merge docs-installation-instructions: status: todo comment: Docs PR 36087 - docs-removal-instructions: - status: todo - comment: Docs PR 36055 (done, but mark todo for benchmark) + docs-removal-instructions: done docs-actions: done brands: done ## Silver config-entry-unloading: done log-when-unavailable: done entity-unavailable: done - action-exceptions: - status: todo - comment: Climate exception on ValueError should be ServiceValidationError + action-exceptions: done reauthentication-flow: status: exempt comment: The hubs have a hardcoded `Smile ID` printed on the sticker used as password, it can not be changed - parallel-updates: - status: todo - comment: Using coordinator, but required due to mutable platform - test-coverage: - status: todo - comment: Consider using snapshots + consistency in setup calls + add numerical tests + use fixtures + parallel-updates: done + test-coverage: done integration-owner: done docs-installation-parameters: status: todo - comment: Docs PR 36087 (partial) + todo rewrite generically + comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) docs-configuration-parameters: status: exempt comment: Plugwise has no options flow @@ -68,41 +50,35 @@ rules: discovery: done stale-devices: done diagnostics: done - exception-translations: - status: todo - comment: Add coordinator, util and climate exceptions + exception-translations: done icon-translations: done reconfiguration-flow: status: todo comment: This integration does not have any reconfiguration steps (yet) investigate how/why - dynamic-devices: - status: todo - comment: Add missing logic to button for unloading and creation + dynamic-devices: done discovery-update-info: done repair-issues: status: exempt comment: This integration does not have repairs docs-use-cases: status: todo - comment: Check for completeness + comment: Check for completeness, PR prepared waiting for 36087 merge docs-supported-devices: status: todo - comment: The list is there but could be improved for readability + comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge docs-supported-functions: status: todo - comment: Check for completeness - docs-data-update: - status: todo - comment: Docs PR 36055 (done, but mark todo for benchmark) + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-data-update: done docs-known-limitations: status: todo - comment: Partial in 36087 but could be more elaborat + comment: Partial in 36087 but could be more elaborate docs-troubleshooting: status: todo - comment: Check for completeness + comment: Check for completeness, PR prepared waiting for 36087 merge docs-examples: status: todo - comment: Check for completeness + comment: Check for completeness, PR prepared waiting for 36087 merge ## Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 46b27ca6225..ff268d8eded 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -15,6 +15,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PlugwiseSelectEntityDescription(SelectEntityDescription): diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 41ca439451a..14b42682376 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -31,6 +31,9 @@ from . import PlugwiseConfigEntry from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class PlugwiseSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index f74fc036e2a..87a8e120591 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -11,7 +11,10 @@ "username": "Smile Username" }, "data_description": { - "host": "Leave empty if using Auto Discovery" + "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.", + "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise App.", + "port": "By default your Smile uses port 80, normally you should not have to change this.", + "username": "Default is `smile`, or `stretch` for the legacy Stretch." } } }, @@ -281,5 +284,28 @@ "name": "Relay" } } + }, + "exceptions": { + "authentication_failed": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "data_incomplete_or_missing": { + "message": "Data incomplete or missing." + }, + "error_communicating_with_api": { + "message": "Error communicating with API: {error}." + }, + "failed_to_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_xml_data": { + "message": "[%key:component::plugwise::config::error::response_error%]" + }, + "unsupported_firmware": { + "message": "[%key:component::plugwise::config::error::unsupported%]" + }, + "unsupported_hvac_mode_requested": { + "message": "Unsupported mode {hvac_mode} requested, valid modes are: {hvac_modes}." + } } } diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 305518f4bef..ea6d6f18b7f 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -21,6 +21,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class PlugwiseSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py index d998711f2b9..c830e5f69f3 100644 --- a/homeassistant/components/plugwise/util.py +++ b/homeassistant/components/plugwise/util.py @@ -7,6 +7,7 @@ from plugwise.exceptions import PlugwiseException from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .entity import PlugwiseEntity @@ -24,10 +25,14 @@ def plugwise_command[_PlugwiseEntityT: PlugwiseEntity, **_P, _R]( ) -> _R: try: return await func(self, *args, **kwargs) - except PlugwiseException as error: + except PlugwiseException as err: raise HomeAssistantError( - f"Error communicating with API: {error}" - ) from error + translation_domain=DOMAIN, + translation_key="error_communicating_with_api", + translation_placeholders={ + "error": str(err), + }, + ) from err finally: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py new file mode 100644 index 00000000000..243f3aacc4f --- /dev/null +++ b/homeassistant/components/powerfox/__init__.py @@ -0,0 +1,55 @@ +"""The Powerfox integration.""" + +from __future__ import annotations + +import asyncio + +from powerfox import Powerfox, PowerfoxConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import PowerfoxDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool: + """Set up Powerfox from a config entry.""" + client = Powerfox( + username=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + try: + devices = await client.all_devices() + except PowerfoxConnectionError as err: + await client.close() + raise ConfigEntryNotReady from err + + coordinators: list[PowerfoxDataUpdateCoordinator] = [ + PowerfoxDataUpdateCoordinator(hass, client, device) for device in devices + ] + + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ] + ) + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/powerfox/config_flow.py b/homeassistant/components/powerfox/config_flow.py new file mode 100644 index 00000000000..dd17badf881 --- /dev/null +++ b/homeassistant/components/powerfox/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for Powerfox integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from powerfox import Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + + +class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Powerfox.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + client = Powerfox( + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=STEP_USER_DATA_SCHEMA, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication flow for Powerfox.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication flow for Powerfox.""" + errors = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + client = Powerfox( + username=reauth_entry.data[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, + data_schema=STEP_REAUTH_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure Powerfox configuration.""" + errors = {} + + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + client = Powerfox( + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + if reconfigure_entry.data[CONF_EMAIL] != user_input[CONF_EMAIL]: + self._async_abort_entries_match( + {CONF_EMAIL: user_input[CONF_EMAIL]} + ) + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/powerfox/const.py b/homeassistant/components/powerfox/const.py new file mode 100644 index 00000000000..0970e8a1b66 --- /dev/null +++ b/homeassistant/components/powerfox/const.py @@ -0,0 +1,11 @@ +"""Constants for the Powerfox integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "powerfox" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py new file mode 100644 index 00000000000..f7ec5ab6716 --- /dev/null +++ b/homeassistant/components/powerfox/coordinator.py @@ -0,0 +1,49 @@ +"""Coordinator for Powerfox integration.""" + +from __future__ import annotations + +from powerfox import ( + Device, + Powerfox, + PowerfoxAuthenticationError, + PowerfoxConnectionError, + Poweropti, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): + """Class to manage fetching Powerfox data from the API.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: Powerfox, + device: Device, + ) -> None: + """Initialize global Powerfox data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self.device = device + + async def _async_update_data(self) -> Poweropti: + """Fetch data from Powerfox API.""" + try: + return await self.client.device(device_id=self.device.id) + except PowerfoxAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except PowerfoxConnectionError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py new file mode 100644 index 00000000000..8f6b847fca0 --- /dev/null +++ b/homeassistant/components/powerfox/diagnostics.py @@ -0,0 +1,58 @@ +"""Support for Powerfox diagnostics.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from powerfox import PowerMeter, WaterMeter + +from homeassistant.core import HomeAssistant + +from . import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PowerfoxConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Powerfox config entry.""" + powerfox_data: list[PowerfoxDataUpdateCoordinator] = entry.runtime_data + + return { + "devices": [ + { + **( + { + "power_meter": { + "outdated": coordinator.data.outdated, + "timestamp": datetime.strftime( + coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S" + ), + "power": coordinator.data.power, + "energy_usage": coordinator.data.energy_usage, + "energy_return": coordinator.data.energy_return, + "energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff, + "energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff, + } + } + if isinstance(coordinator.data, PowerMeter) + else {} + ), + **( + { + "water_meter": { + "outdated": coordinator.data.outdated, + "timestamp": datetime.strftime( + coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S" + ), + "cold_water": coordinator.data.cold_water, + "warm_water": coordinator.data.warm_water, + } + } + if isinstance(coordinator.data, WaterMeter) + else {} + ), + } + for coordinator in powerfox_data + ], + } diff --git a/homeassistant/components/powerfox/entity.py b/homeassistant/components/powerfox/entity.py new file mode 100644 index 00000000000..0ab7200ffe8 --- /dev/null +++ b/homeassistant/components/powerfox/entity.py @@ -0,0 +1,32 @@ +"""Generic entity for Powerfox.""" + +from __future__ import annotations + +from powerfox import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PowerfoxDataUpdateCoordinator + + +class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]): + """Base entity for Powerfox.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PowerfoxDataUpdateCoordinator, + device: Device, + ) -> None: + """Initialize Powerfox entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + manufacturer="Powerfox", + model=device.type.human_readable, + name=device.name, + serial_number=device.id, + ) diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json new file mode 100644 index 00000000000..7083ffe8de7 --- /dev/null +++ b/homeassistant/components/powerfox/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "powerfox", + "name": "Powerfox", + "codeowners": ["@klaasnicolaas"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/powerfox", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["powerfox==1.0.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "powerfox*" + } + ] +} diff --git a/homeassistant/components/powerfox/quality_scale.yaml b/homeassistant/components/powerfox/quality_scale.yaml new file mode 100644 index 00000000000..f72d25c3684 --- /dev/null +++ b/homeassistant/components/powerfox/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + This integration uses a coordinator to handle updates. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration is connecting to a cloud service. + discovery: + status: done + comment: | + It can find poweropti devices via zeroconf, and will start a normal user flow. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + There is no need for icon translations. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py new file mode 100644 index 00000000000..7771f96dd81 --- /dev/null +++ b/homeassistant/components/powerfox/sensor.py @@ -0,0 +1,146 @@ +"""Sensors for Powerfox integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from powerfox import Device, PowerMeter, WaterMeter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PowerfoxConfigEntry +from .coordinator import PowerfoxDataUpdateCoordinator +from .entity import PowerfoxEntity + + +@dataclass(frozen=True, kw_only=True) +class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter)]( + SensorEntityDescription +): + """Describes Poweropti sensor entity.""" + + value_fn: Callable[[T], float | int | None] + + +SENSORS_POWER: tuple[PowerfoxSensorEntityDescription[PowerMeter], ...] = ( + PowerfoxSensorEntityDescription[PowerMeter]( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda meter: meter.power, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_usage", + translation_key="energy_usage", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_usage, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_usage_low_tariff", + translation_key="energy_usage_low_tariff", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_usage_low_tariff, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_usage_high_tariff", + translation_key="energy_usage_high_tariff", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_usage_high_tariff, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_return", + translation_key="energy_return", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_return, + ), +) + + +SENSORS_WATER: tuple[PowerfoxSensorEntityDescription[WaterMeter], ...] = ( + PowerfoxSensorEntityDescription[WaterMeter]( + key="cold_water", + translation_key="cold_water", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.cold_water, + ), + PowerfoxSensorEntityDescription[WaterMeter]( + key="warm_water", + translation_key="warm_water", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.warm_water, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PowerfoxConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Powerfox sensors based on a config entry.""" + entities: list[SensorEntity] = [] + for coordinator in entry.runtime_data: + if isinstance(coordinator.data, PowerMeter): + entities.extend( + PowerfoxSensorEntity( + coordinator=coordinator, + description=description, + device=coordinator.device, + ) + for description in SENSORS_POWER + if description.value_fn(coordinator.data) is not None + ) + if isinstance(coordinator.data, WaterMeter): + entities.extend( + PowerfoxSensorEntity( + coordinator=coordinator, + description=description, + device=coordinator.device, + ) + for description in SENSORS_WATER + ) + async_add_entities(entities) + + +class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity): + """Defines a powerfox power meter sensor.""" + + entity_description: PowerfoxSensorEntityDescription + + def __init__( + self, + coordinator: PowerfoxDataUpdateCoordinator, + device: Device, + description: PowerfoxSensorEntityDescription, + ) -> None: + """Initialize Powerfox power meter sensor.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.id}_{description.key}" + + @property + def native_value(self) -> float | int | None: + """Return the state of the entity.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json new file mode 100644 index 00000000000..4a7c8e8fa4d --- /dev/null +++ b/homeassistant/components/powerfox/strings.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "description": "Connect to your Powerfox account to get information about your energy, heat or water consumption.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your Powerfox account.", + "password": "The password of your Powerfox account." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The password for {email} is no longer valid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::powerfox::config::step::user::data_description::password%]" + } + }, + "reconfigure": { + "title": "Reconfigure your Powerfox account", + "description": "Powerfox is already configured. Would you like to reconfigure it?", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::powerfox::config::step::user::data_description::email%]", + "password": "[%key:component::powerfox::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "entity": { + "sensor": { + "energy_usage": { + "name": "Energy usage" + }, + "energy_usage_low_tariff": { + "name": "Energy usage low tariff" + }, + "energy_usage_high_tariff": { + "name": "Energy usage high tariff" + }, + "energy_return": { + "name": "Energy return" + }, + "cold_water": { + "name": "Cold water" + }, + "warm_water": { + "name": "Warm water" + } + } + } +} diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 9423d65b0fc..28506e2a60c 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter, methodcaller -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING from tesla_powerwall import GridState, MeterResponse, MeterType @@ -35,14 +35,12 @@ from .models import BatteryResponse, PowerwallConfigEntry, PowerwallRuntimeData _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" -_ValueParamT = TypeVar("_ValueParamT") -_ValueT = TypeVar("_ValueT", bound=float | int | str | None) +type _ValueType = float | int | str | None @dataclass(frozen=True, kw_only=True) -class PowerwallSensorEntityDescription( - SensorEntityDescription, - Generic[_ValueParamT, _ValueT], +class PowerwallSensorEntityDescription[_ValueParamT, _ValueT: _ValueType]( + SensorEntityDescription ): """Describes Powerwall entity.""" @@ -389,7 +387,7 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor): return meter.get_energy_imported() -class PowerWallBatterySensor(BatteryEntity, SensorEntity, Generic[_ValueT]): +class PowerWallBatterySensor[_ValueT: _ValueType](BatteryEntity, SensorEntity): """Representation of an Powerwall Battery sensor.""" entity_description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT] diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index 18b974800a3..be7d394993a 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -61,7 +61,6 @@ class ProliphixThermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _enable_turn_on_off_backwards_compatibility = False def __init__(self, pdp): """Initialize the thermostat.""" diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py index a3b3af857fb..f7c44b7e9b2 100644 --- a/homeassistant/components/purpleair/diagnostics.py +++ b/homeassistant/components/purpleair/diagnostics.py @@ -37,7 +37,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "data": coordinator.data.dict(), + "data": coordinator.data.model_dump(), }, TO_REDACT, ) diff --git a/homeassistant/components/purpleair/manifest.json b/homeassistant/components/purpleair/manifest.json index cf74365d6d8..87cb375c347 100644 --- a/homeassistant/components/purpleair/manifest.json +++ b/homeassistant/components/purpleair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/purpleair", "iot_class": "cloud_polling", - "requirements": ["aiopurpleair==2022.12.1"] + "requirements": ["aiopurpleair==2023.12.0"] } diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index bc96bc5061d..9dbdad53bcb 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["pvo==2.1.1"] + "requirements": ["pvo==2.2.0"] } diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index ba1896cba2f..cfbee0be67c 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -55,7 +55,6 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 73ab3644a0b..af52c5fcea3 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -107,7 +107,6 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_precision = PRECISION_HALVES _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index cd000c63fad..8b4805a9b0e 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -34,21 +34,31 @@ rules: docs-removal-instructions: todo test-before-setup: done docs-high-level-description: done - config-flow-test-coverage: done + config-flow-test-coverage: + status: todo + comment: | + All config flow tests should finish with CREATE_ENTRY and ABORT to + test they are able to recover from errors docs-actions: done runtime-data: done # Silver - log-when-unavailable: todo - config-entry-unloading: todo + log-when-unavailable: done + config-entry-unloading: done reauthentication-flow: done - action-exceptions: todo - docs-installation-parameters: todo - integration-owner: todo - parallel-updates: todo - test-coverage: todo - docs-configuration-parameters: todo - entity-unavailable: todo + action-exceptions: done + docs-installation-parameters: + status: todo + comment: The documentation does not mention installation parameters + integration-owner: done + parallel-updates: + status: todo + comment: The integration does not explicitly set a number of parallel updates. + test-coverage: done + docs-configuration-parameters: + status: todo + comment: The documentation for configuration parameters could be improved. + entity-unavailable: done # Gold docs-examples: todo diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index 00314169260..35b7757580e 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -106,8 +106,12 @@ def _validate_unit(options: dict[str, Any]) -> None: and (units := DEVICE_CLASS_UNITS.get(device_class)) and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): + # Sort twice to make sure strings with same case-insensitive order of + # letters are sorted consistently still (sorted() is guaranteed stable). sorted_units = sorted( - [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + sorted( + [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + ), key=str.casefold, ) if len(sorted_units) == 1: diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a3163d5b396..76cf0a7c05e 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1430,7 +1430,6 @@ class Recorder(threading.Thread): with session_scope(session=self.get_session()) as session: end_incomplete_runs(session, self.recorder_runs_manager.recording_start) self.recorder_runs_manager.start(session) - self.states_manager.load_from_db(session) self._open_event_session() diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index dc49ebb9768..da90b296fe3 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance import homeassistant.util.dt as dt_util -from ..db_schema import StateAttributes, States +from ..db_schema import RecorderRuns, StateAttributes, States from ..filters import Filters -from ..models import process_timestamp_to_utc_isoformat +from ..models import process_timestamp, process_timestamp_to_utc_isoformat from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state from ..util import execute_stmt_lambda_element, session_scope from .const import ( @@ -436,7 +436,7 @@ def get_last_state_changes( def _get_states_for_entities_stmt( - run_start_ts: float, + run_start: datetime, utc_point_in_time: datetime, entity_ids: list[str], no_attributes: bool, @@ -447,6 +447,7 @@ def _get_states_for_entities_stmt( ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. + run_start_ts = process_timestamp(run_start).timestamp() utc_point_in_time_ts = utc_point_in_time.timestamp() stmt += lambda q: q.join( ( @@ -482,7 +483,7 @@ def _get_rows_with_session( session: Session, utc_point_in_time: datetime, entity_ids: list[str], - *, + run: RecorderRuns | None = None, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" @@ -494,16 +495,17 @@ def _get_rows_with_session( ), ) - oldest_ts = get_instance(hass).states_manager.oldest_ts + if run is None: + run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) - if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp(): - # We don't have any states for the requested time + if run is None or process_timestamp(run.start) > utc_point_in_time: + # History did not run before utc_point_in_time return [] # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. stmt = _get_states_for_entities_stmt( - oldest_ts, utc_point_in_time, entity_ids, no_attributes + run.start, utc_point_in_time, entity_ids, no_attributes ) return execute_stmt_lambda_element(session, stmt) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 01551de1f28..9159bbc6181 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -34,6 +34,7 @@ from ..models import ( LazyState, datetime_to_timestamp_or_none, extract_metadata_ids, + process_timestamp, row_to_compressed_state, ) from ..util import execute_stmt_lambda_element, session_scope @@ -245,9 +246,9 @@ def get_significant_states_with_session( if metadata_id is not None and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS ] - oldest_ts: float | None = None + run_start_ts: float | None = None if include_start_time_state and not ( - oldest_ts := _get_oldest_possible_ts(hass, start_time) + run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) ): include_start_time_state = False start_time_ts = start_time.timestamp() @@ -263,7 +264,7 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, - oldest_ts, + run_start_ts, ), track_on=[ bool(single_metadata_id), @@ -410,9 +411,9 @@ def state_changes_during_period( entity_id_to_metadata_id: dict[str, int | None] = { entity_id: single_metadata_id } - oldest_ts: float | None = None + run_start_ts: float | None = None if include_start_time_state and not ( - oldest_ts := _get_oldest_possible_ts(hass, start_time) + run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) ): include_start_time_state = False start_time_ts = start_time.timestamp() @@ -425,7 +426,7 @@ def state_changes_during_period( no_attributes, limit, include_start_time_state, - oldest_ts, + run_start_ts, has_last_reported, ), track_on=[ @@ -599,17 +600,17 @@ def _get_start_time_state_for_entities_stmt( ) -def _get_oldest_possible_ts( +def _get_run_start_ts_for_utc_point_in_time( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: - """Return the oldest possible timestamp. - - Returns None if there are no states as old as utc_point_in_time. - """ - - oldest_ts = get_instance(hass).states_manager.oldest_ts - if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp(): - return oldest_ts + """Return the start time of a run.""" + run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + if ( + run is not None + and (run_start := process_timestamp(run.start)) < utc_point_in_time + ): + return run_start.timestamp() + # History did not run before utc_point_in_time but we still return None diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index fffecff149c..b28ca4399c8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2508,15 +2508,11 @@ class EventsContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): return has_events_context_ids_to_migrate() -class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): +class EventTypeIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to migrate event_type to event_type_ids.""" required_schema_version = EVENT_TYPE_IDS_SCHEMA_VERSION migration_id = "event_type_id_migration" - task = CommitBeforeMigrationTask - # We have to commit before to make sure there are - # no new pending event_types about to be added to - # the db since this happens live def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate event_type to event_type_ids, return True if completed.""" @@ -2576,25 +2572,16 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): _LOGGER.debug("Migrating event_types done=%s", is_done) return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) - def migration_done(self, instance: Recorder, session: Session) -> None: - """Will be called after migrate returns True.""" - _LOGGER.debug("Activating event_types manager as all data is migrated") - instance.event_type_manager.active = True - def needs_migrate_query(self) -> StatementLambdaElement: """Check if the data is migrated.""" return has_event_type_to_migrate() -class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): +class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to migrate entity_ids to states_meta.""" required_schema_version = STATES_META_SCHEMA_VERSION migration_id = "entity_id_migration" - task = CommitBeforeMigrationTask - # We have to commit before to make sure there are - # no new pending states_meta about to be added to - # the db since this happens live def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate entity_ids to states_meta, return True if completed. @@ -2664,18 +2651,6 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): _LOGGER.debug("Migrating entity_ids done=%s", is_done) return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) - def migration_done(self, instance: Recorder, session: Session) -> None: - """Will be called after migrate returns True.""" - # The migration has finished, now we start the post migration - # to remove the old entity_id data from the states table - # at this point we can also start using the StatesMeta table - # so we set active to True - _LOGGER.debug("Activating states_meta manager as all data is migrated") - instance.states_meta_manager.active = True - with contextlib.suppress(SQLAlchemyError): - migrate = EntityIDPostMigration(self.schema_version, self.migration_changes) - migrate.queue_migration(instance, session) - def needs_migrate_query(self) -> StatementLambdaElement: """Check if the data is migrated.""" return has_entity_ids_to_migrate() @@ -2763,14 +2738,13 @@ class EventIDPostMigration(BaseRunTimeMigration): return DataMigrationStatus(needs_migrate=False, migration_done=True) -class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration): +class EntityIDPostMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to remove old entity_id strings from states. Introduced in HA Core 2023.4 by PR #89557. """ migration_id = "entity_id_post_migration" - task = MigrationTask index_to_drop = (TABLE_STATES, LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX) def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: @@ -2783,14 +2757,15 @@ class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration): return has_used_states_entity_ids() -NON_LIVE_DATA_MIGRATORS = ( +NON_LIVE_DATA_MIGRATORS: tuple[type[BaseOffLineMigration], ...] = ( StatesContextIDMigration, # Introduced in HA Core 2023.4 EventsContextIDMigration, # Introduced in HA Core 2023.4 -) - -LIVE_DATA_MIGRATORS = ( EventTypeIDMigration, # Introduced in HA Core 2023.4 by PR #89465 EntityIDMigration, # Introduced in HA Core 2023.4 by PR #89557 + EntityIDPostMigration, # Introduced in HA Core 2023.4 by PR #89557 +) + +LIVE_DATA_MIGRATORS: tuple[type[BaseRunTimeMigration], ...] = ( EventIDPostMigration, # Introduced in HA Core 2023.4 by PR #89901 ) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 89281a85c15..fbf73e75025 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -96,6 +96,29 @@ class LazyState(State): assert self._last_updated_ts is not None return dt_util.utc_from_timestamp(self._last_updated_ts) + @cached_property + def last_updated_timestamp(self) -> float: # type: ignore[override] + """Last updated timestamp.""" + if TYPE_CHECKING: + assert self._last_updated_ts is not None + return self._last_updated_ts + + @cached_property + def last_changed_timestamp(self) -> float: # type: ignore[override] + """Last changed timestamp.""" + ts = self._last_changed_ts or self._last_updated_ts + if TYPE_CHECKING: + assert ts is not None + return ts + + @cached_property + def last_reported_timestamp(self) -> float: # type: ignore[override] + """Last reported timestamp.""" + ts = self._last_reported_ts or self._last_updated_ts + if TYPE_CHECKING: + assert ts is not None + return ts + def as_dict(self) -> dict[str, Any]: # type: ignore[override] """Return a dict representation of the LazyState. diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 28a5a2ed32d..eb67300e8d4 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -116,16 +116,12 @@ def purge_old_data( # This purge cycle is finished, clean up old event types and # recorder runs - if instance.event_type_manager.active: - _purge_old_event_types(instance, session) + _purge_old_event_types(instance, session) if instance.states_meta_manager.active: _purge_old_entity_ids(instance, session) _purge_old_recorder_runs(instance, session, purge_before) - with session_scope(session=instance.get_session(), read_only=True) as session: - instance.recorder_runs_manager.load_from_db(session) - instance.states_manager.load_from_db(session) if repack: repack_database(instance) return True diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 8ca7bef2691..2e4b588a0b0 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -637,15 +637,6 @@ def find_states_to_purge( ) -def find_oldest_state() -> StatementLambdaElement: - """Find the last_updated_ts of the oldest state.""" - return lambda_stmt( - lambda: select(States.last_updated_ts).where( - States.state_id.in_(select(func.min(States.state_id))) - ) - ) - - def find_short_term_statistics_to_purge( purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 81bddce948d..266c970fe1f 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -28,8 +28,6 @@ CACHE_SIZE = 2048 class EventTypeManager(BaseLRUTableManager[EventTypes]): """Manage the EventTypes table.""" - active = False - def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index 4ca0aa18b88..b0b9818118b 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -2,6 +2,8 @@ from __future__ import annotations +import bisect +from dataclasses import dataclass from datetime import datetime from sqlalchemy.orm.session import Session @@ -9,6 +11,34 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util from ..db_schema import RecorderRuns +from ..models import process_timestamp + + +def _find_recorder_run_for_start_time( + run_history: _RecorderRunsHistory, start: datetime +) -> RecorderRuns | None: + """Find the recorder run for a start time in _RecorderRunsHistory.""" + run_timestamps = run_history.run_timestamps + runs_by_timestamp = run_history.runs_by_timestamp + + # bisect_left tells us were we would insert + # a value in the list of runs after the start timestamp. + # + # The run before that (idx-1) is when the run started + # + # If idx is 0, history never ran before the start timestamp + # + if idx := bisect.bisect_left(run_timestamps, start.timestamp()): + return runs_by_timestamp[run_timestamps[idx - 1]] + return None + + +@dataclass(frozen=True) +class _RecorderRunsHistory: + """Bisectable history of RecorderRuns.""" + + run_timestamps: list[int] + runs_by_timestamp: dict[int, RecorderRuns] class RecorderRunsManager: @@ -18,7 +48,7 @@ class RecorderRunsManager: """Track recorder run history.""" self._recording_start = dt_util.utcnow() self._current_run_info: RecorderRuns | None = None - self._first_run: RecorderRuns | None = None + self._run_history = _RecorderRunsHistory([], {}) @property def recording_start(self) -> datetime: @@ -28,7 +58,9 @@ class RecorderRunsManager: @property def first(self) -> RecorderRuns: """Get the first run.""" - return self._first_run or self.current + if runs_by_timestamp := self._run_history.runs_by_timestamp: + return next(iter(runs_by_timestamp.values())) + return self.current @property def current(self) -> RecorderRuns: @@ -46,6 +78,15 @@ class RecorderRunsManager: """Return if a run is active.""" return self._current_run_info is not None + def get(self, start: datetime) -> RecorderRuns | None: + """Return the recorder run that started before or at start. + + If the first run started after the start, return None + """ + if start >= self.recording_start: + return self.current + return _find_recorder_run_for_start_time(self._run_history, start) + def start(self, session: Session) -> None: """Start a new run. @@ -81,17 +122,31 @@ class RecorderRunsManager: Must run in the recorder thread. """ - if ( - run := session.query(RecorderRuns) - .order_by(RecorderRuns.start.asc()) - .first() - ): + run_timestamps: list[int] = [] + runs_by_timestamp: dict[int, RecorderRuns] = {} + + for run in session.query(RecorderRuns).order_by(RecorderRuns.start.asc()).all(): session.expunge(run) - self._first_run = run + if run_dt := process_timestamp(run.start): + # Not sure if this is correct or runs_by_timestamp annotation should be changed + timestamp = int(run_dt.timestamp()) + run_timestamps.append(timestamp) + runs_by_timestamp[timestamp] = run + + # + # self._run_history is accessed in get() + # which is allowed to be called from any thread + # + # We use a dataclass to ensure that when we update + # run_timestamps and runs_by_timestamp + # are never out of sync with each other. + # + self._run_history = _RecorderRunsHistory(run_timestamps, runs_by_timestamp) def clear(self) -> None: """Clear the current run after ending it. Must run in the recorder thread. """ - self._current_run_info = None + if self._current_run_info: + self._current_run_info = None diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py index fafcfa0ea61..d5cef759c54 100644 --- a/homeassistant/components/recorder/table_managers/states.py +++ b/homeassistant/components/recorder/table_managers/states.py @@ -2,15 +2,7 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any, cast - -from sqlalchemy.engine.row import Row -from sqlalchemy.orm.session import Session - from ..db_schema import States -from ..queries import find_oldest_state -from ..util import execute_stmt_lambda_element class StatesManager: @@ -21,12 +13,6 @@ class StatesManager: self._pending: dict[str, States] = {} self._last_committed_id: dict[str, int] = {} self._last_reported: dict[int, float] = {} - self._oldest_ts: float | None = None - - @property - def oldest_ts(self) -> float | None: - """Return the oldest timestamp.""" - return self._oldest_ts def pop_pending(self, entity_id: str) -> States | None: """Pop a pending state. @@ -58,8 +44,6 @@ class StatesManager: recorder thread. """ self._pending[entity_id] = state - if self._oldest_ts is None: - self._oldest_ts = state.last_updated_ts def update_pending_last_reported( self, state_id: int, last_reported_timestamp: float @@ -90,22 +74,6 @@ class StatesManager: """ self._last_committed_id.clear() self._pending.clear() - self._oldest_ts = None - - def load_from_db(self, session: Session) -> None: - """Update the cache. - - Must run in the recorder thread. - """ - result = cast( - Sequence[Row[Any]], - execute_stmt_lambda_element(session, find_oldest_state()), - ) - if not result: - ts = None - else: - ts = result[0].last_updated_ts - self._oldest_ts = ts def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None: """Evict purged states from the committed states. diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 80d20dbec94..75afb6589a1 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -24,7 +24,7 @@ CACHE_SIZE = 8192 class StatesMetaManager(BaseLRUTableManager[StatesMeta]): """Manage the StatesMeta table.""" - active = False + active = True def __init__(self, recorder: Recorder) -> None: """Initialize the states meta manager.""" diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index fa10c12aa68..783f0a80b8e 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -120,6 +120,8 @@ class PurgeTask(RecorderTask): if purge.purge_old_data( instance, self.purge_before, self.repack, self.apply_filter ): + with instance.get_session() as session: + instance.recorder_runs_manager.load_from_db(session) # We always need to do the db cleanups after a purge # is finished to ensure the WAL checkpoint and other # tasks happen after a vacuum. diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a59519ef38d..2e7ac0c092d 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -892,17 +892,16 @@ def resolve_period( start_time += timedelta(days=cal_offset * 7) end_time = start_time + timedelta(weeks=1) elif calendar_period == "month": - start_time = start_of_day.replace(day=28) - # This works for up to 48 months of offset - start_time = (start_time + timedelta(days=cal_offset * 31)).replace(day=1) + month_now = start_of_day.month + new_month = (month_now - 1 + cal_offset) % 12 + 1 + new_year = start_of_day.year + (month_now - 1 + cal_offset) // 12 + start_time = start_of_day.replace(year=new_year, month=new_month, day=1) end_time = (start_time + timedelta(days=31)).replace(day=1) else: # calendar_period = "year" - start_time = start_of_day.replace(month=12, day=31) - # This works for 100+ years of offset - start_time = (start_time + timedelta(days=cal_offset * 366)).replace( - month=1, day=1 + start_time = start_of_day.replace( + year=start_of_day.year + cal_offset, month=1, day=1 ) - end_time = (start_time + timedelta(days=365)).replace(day=1) + end_time = (start_time + timedelta(days=366)).replace(day=1) start_time = dt_util.as_utc(start_time) end_time = dt_util.as_utc(end_time) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 9c54a40be70..36e482f0a29 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -170,19 +170,6 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) """Flag supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> RemoteEntityFeature: - """Return the supported features as RemoteEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = RemoteEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def current_activity(self) -> str | None: """Active activity.""" @@ -197,7 +184,7 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat: + if RemoteEntityFeature.ACTIVITY not in self.supported_features: return None return { diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 44bea28ce3c..56b3655ef94 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -127,7 +127,6 @@ class RensonFan(RensonEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, api: RensonVentilation, coordinator: RensonCoordinator) -> None: """Initialize the Renson fan.""" diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 72bf21ccfd9..7aced174e30 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.4"] + "requirements": ["reolink-aio==0.11.5"] } diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index cc195c9944e..4f8ae9767e2 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -182,13 +182,11 @@ SENSOR_TYPES = ( key="Count", translation_key="count", state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", translation_key="counter_value", state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Chill", diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index c226c1c590d..149b8761589 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.4"] + "requirements": ["pyrisco==0.6.5"] } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 3b873f259e4..d3413bd7cbd 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -8,13 +8,8 @@ from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse @@ -27,29 +22,29 @@ from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 STATE_CODE_TO_STATE = { - RoborockStateCode.starting: STATE_IDLE, # "Starting" - RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected" - RoborockStateCode.idle: STATE_IDLE, # "Idle" - RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active" - RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning" - RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home" - RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode" - RoborockStateCode.charging: STATE_DOCKED, # "Charging" - RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem" - RoborockStateCode.paused: STATE_PAUSED, # "Paused" - RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning" - RoborockStateCode.error: STATE_ERROR, # "Error" - RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down" - RoborockStateCode.updating: STATE_DOCKED, # "Updating" - RoborockStateCode.docking: STATE_RETURNING, # "Docking" - RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target" - RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning" - RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning" - RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+ - RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV - RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV - RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete" - RoborockStateCode.device_offline: STATE_ERROR, # "Device offline" + RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" + RoborockStateCode.charger_disconnected: VacuumActivity.IDLE, # "Charger disconnected" + RoborockStateCode.idle: VacuumActivity.IDLE, # "Idle" + RoborockStateCode.remote_control_active: VacuumActivity.CLEANING, # "Remote control active" + RoborockStateCode.cleaning: VacuumActivity.CLEANING, # "Cleaning" + RoborockStateCode.returning_home: VacuumActivity.RETURNING, # "Returning home" + RoborockStateCode.manual_mode: VacuumActivity.CLEANING, # "Manual mode" + RoborockStateCode.charging: VacuumActivity.DOCKED, # "Charging" + RoborockStateCode.charging_problem: VacuumActivity.ERROR, # "Charging problem" + RoborockStateCode.paused: VacuumActivity.PAUSED, # "Paused" + RoborockStateCode.spot_cleaning: VacuumActivity.CLEANING, # "Spot cleaning" + RoborockStateCode.error: VacuumActivity.ERROR, # "Error" + RoborockStateCode.shutting_down: VacuumActivity.IDLE, # "Shutting down" + RoborockStateCode.updating: VacuumActivity.DOCKED, # "Updating" + RoborockStateCode.docking: VacuumActivity.RETURNING, # "Docking" + RoborockStateCode.going_to_target: VacuumActivity.CLEANING, # "Going to target" + RoborockStateCode.zoned_cleaning: VacuumActivity.CLEANING, # "Zoned cleaning" + RoborockStateCode.segment_cleaning: VacuumActivity.CLEANING, # "Segment cleaning" + RoborockStateCode.emptying_the_bin: VacuumActivity.DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode.washing_the_mop: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode.going_to_wash_the_mop: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode.charging_complete: VacuumActivity.DOCKED, # "Charging complete" + RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline" } @@ -112,7 +107,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): self._attr_fan_speed_list = self._device_status.fan_power_options @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" assert self._device_status.state is not None return STATE_CODE_TO_STATE.get(self._device_status.state) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index b318a91e4c7..e6b92d91335 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN +from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID from .coordinator import RokuDataUpdateCoordinator PLATFORMS = [ @@ -17,8 +17,10 @@ PLATFORMS = [ Platform.SENSOR, ] +type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Set up Roku from a config entry.""" if (device_id := entry.unique_id) is None: device_id = entry.entry_id @@ -33,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -42,13 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: """Reload the config entry when it changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 0f5f29f63f6..2e7fd12788c 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -11,14 +11,16 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import RokuConfigEntry from .entity import RokuEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RokuBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -56,15 +58,13 @@ BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Roku binary sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( RokuBinarySensorEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=description, ) for description in BINARY_SENSORS diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 18e3b3ed68a..bc0092d6953 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( - ConfigEntry, + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -20,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import RokuConfigEntry from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -57,20 +58,38 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): self.discovery_info = {} @callback - def _show_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowResult: + def _show_form( + self, + user_input: dict[str, Any] | None, + errors: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """Show the form to the user.""" + suggested_values = user_input + if suggested_values is None and self.source == SOURCE_RECONFIGURE: + suggested_values = { + CONF_HOST: self._get_reconfigure_entry().data[CONF_HOST] + } + return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, suggested_values + ), errors=errors or {}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + return await self.async_step_user(user_input) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" if not user_input: - return self._show_form() + return self._show_form(user_input) errors = {} @@ -79,13 +98,21 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): except RokuError: _LOGGER.debug("Roku Error", exc_info=True) errors["base"] = ERROR_CANNOT_CONNECT - return self._show_form(errors) + return self._show_form(user_input, errors) except Exception: _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) await self.async_set_unique_id(info["serial_number"]) - self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) + + self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) @@ -164,7 +191,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RokuConfigEntry, ) -> RokuOptionsFlowHandler: """Create the options flow.""" return RokuOptionsFlowHandler() diff --git a/homeassistant/components/roku/diagnostics.py b/homeassistant/components/roku/diagnostics.py index 6c6809ee33a..e98837ca442 100644 --- a/homeassistant/components/roku/diagnostics.py +++ b/homeassistant/components/roku/diagnostics.py @@ -4,25 +4,21 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, entry: RokuConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - return { "entry": { "data": { - **config_entry.data, + **entry.data, }, - "unique_id": config_entry.unique_id, + "unique_id": entry.unique_id, }, - "data": coordinator.data.as_dict(), + "data": entry.runtime_data.data.as_dict(), } diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 35f01553cdd..0c1f92521af 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -23,13 +23,13 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType +from . import RokuConfigEntry from .browse_media import async_browse_media from .const import ( ATTR_ARTIST_NAME, @@ -38,7 +38,6 @@ from .const import ( ATTR_KEYWORD, ATTR_MEDIA_TYPE, ATTR_THUMBNAIL, - DOMAIN, SERVICE_SEARCH, ) from .coordinator import RokuDataUpdateCoordinator @@ -47,7 +46,6 @@ from .helpers import format_channel_name, roku_exception_handler _LOGGER = logging.getLogger(__name__) - STREAM_FORMAT_TO_MEDIA_TYPE = { "dash": MediaType.VIDEO, "hls": MediaType.VIDEO, @@ -81,17 +79,17 @@ ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS = { SEARCH_SCHEMA: VolDictType = {vol.Required(ATTR_KEYWORD): str} +PARALLEL_UPDATES = 1 + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Roku config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( [ RokuMediaPlayer( - coordinator=coordinator, + coordinator=entry.runtime_data, ) ], True, diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index fa351e021e8..f7916fb23a2 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -6,28 +6,26 @@ from collections.abc import Iterable from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry from .entity import RokuEntity from .helpers import roku_exception_handler +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Load Roku remote based on a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( [ RokuRemote( - coordinator=coordinator, + coordinator=entry.runtime_data, ) ], True, diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 5f3b9d4049b..360d4e25415 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -9,15 +9,15 @@ from rokuecp import Roku from rokuecp.models import Device as RokuDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler +PARALLEL_UPDATES = 1 + def _get_application_name(device: RokuDevice) -> str | None: if device.app is None or device.app.name is None: @@ -108,16 +108,15 @@ CHANNEL_ENTITY = RokuSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roku select based on a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - device: RokuDevice = coordinator.data + device: RokuDevice = entry.runtime_data.data entities: list[RokuSelectEntity] = [ RokuSelectEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=description, ) for description in ENTITIES @@ -126,7 +125,7 @@ async def async_setup_entry( if len(device.channels) > 0: entities.append( RokuSelectEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=CHANNEL_ENTITY, ) ) diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index ed134cc4c2a..870386945a6 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -8,15 +8,16 @@ from dataclasses import dataclass from rokuecp.models import Device as RokuDevice from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry from .entity import RokuEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RokuSensorEntityDescription(SensorEntityDescription): @@ -43,15 +44,13 @@ SENSORS: tuple[RokuSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roku sensor based on a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( RokuSensorEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=description, ) for description in SENSORS diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 9d657be6d61..bd47585db1b 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -21,7 +21,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_device": "This Roku device does not match the existing device id. Please make sure you entered the correct host information." } }, "options": { diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index de74d371f0e..49129daabbd 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -6,7 +6,11 @@ https://home-assistant.io/components/vacuum.romy/. from typing import Any -from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,7 +79,14 @@ class RomyVacuumEntity(RomyEntity, StateVacuumEntity): """Handle updated data from the coordinator.""" self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed] self._attr_battery_level = self.romy.battery_level - self._attr_state = self.romy.status + if (status := self.romy.status) is None: + self._attr_activity = None + self.async_write_ha_state() + return + try: + self._attr_activity = VacuumActivity(status) + except ValueError: + self._attr_activity = None self.async_write_ha_state() diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py deleted file mode 100644 index 8744561b2c5..00000000000 --- a/homeassistant/components/roomba/braava.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Class for Braava devices.""" - -import logging - -from homeassistant.components.vacuum import VacuumEntityFeature - -from .entity import SUPPORT_IROBOT, IRobotVacuum - -_LOGGER = logging.getLogger(__name__) - -ATTR_DETECTED_PAD = "detected_pad" -ATTR_LID_CLOSED = "lid_closed" -ATTR_TANK_PRESENT = "tank_present" -ATTR_TANK_LEVEL = "tank_level" -ATTR_PAD_WETNESS = "spray_amount" - -OVERLAP_STANDARD = 67 -OVERLAP_DEEP = 85 -OVERLAP_EXTENDED = 25 -MOP_STANDARD = "Standard" -MOP_DEEP = "Deep" -MOP_EXTENDED = "Extended" -BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED] -BRAAVA_SPRAY_AMOUNT = [1, 2, 3] - -# Braava Jets can set mopping behavior through fanspeed -SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED - - -class BraavaJet(IRobotVacuum): # pylint: disable=hass-enforce-class-module - """Braava Jet.""" - - _attr_supported_features = SUPPORT_BRAAVA - - def __init__(self, roomba, blid): - """Initialize the Roomba handler.""" - super().__init__(roomba, blid) - - # Initialize fan speed list - self._attr_fan_speed_list = [ - f"{behavior}-{spray}" - for behavior in BRAAVA_MOP_BEHAVIORS - for spray in BRAAVA_SPRAY_AMOUNT - ] - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - # Mopping behavior and spray amount as fan speed - rank_overlap = self.vacuum_state.get("rankOverlap", {}) - behavior = None - if rank_overlap == OVERLAP_STANDARD: - behavior = MOP_STANDARD - elif rank_overlap == OVERLAP_DEEP: - behavior = MOP_DEEP - elif rank_overlap == OVERLAP_EXTENDED: - behavior = MOP_EXTENDED - pad_wetness = self.vacuum_state.get("padWetness", {}) - # "disposable" and "reusable" values are always the same - pad_wetness_value = pad_wetness.get("disposable") - return f"{behavior}-{pad_wetness_value}" - - async def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - try: - split = fan_speed.split("-", 1) - behavior = split[0] - spray = int(split[1]) - if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: - behavior = behavior.capitalize() - except IndexError: - _LOGGER.error( - "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", - fan_speed, - ) - return - except ValueError: - _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1]) - return - if behavior not in BRAAVA_MOP_BEHAVIORS: - _LOGGER.error( - "Mop behavior error: expected one of %s, got '%s'", - str(BRAAVA_MOP_BEHAVIORS), - behavior, - ) - return - if spray not in BRAAVA_SPRAY_AMOUNT: - _LOGGER.error( - "Spray amount error: expected one of %s, got '%d'", - str(BRAAVA_SPRAY_AMOUNT), - spray, - ) - return - - overlap = 0 - if behavior == MOP_STANDARD: - overlap = OVERLAP_STANDARD - elif behavior == MOP_DEEP: - overlap = OVERLAP_DEEP - else: - overlap = OVERLAP_EXTENDED - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "rankOverlap", overlap - ) - await self.hass.async_add_executor_job( - self.vacuum.set_preference, - "padWetness", - {"disposable": spray, "reusable": spray}, - ) - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - state_attrs = super().extra_state_attributes - - # Get Braava state - state = self.vacuum_state - detected_pad = state.get("detectedPad") - mop_ready = state.get("mopReady", {}) - lid_closed = mop_ready.get("lidClosed") - tank_present = mop_ready.get("tankPresent") - tank_level = state.get("tankLvl") - state_attrs[ATTR_DETECTED_PAD] = detected_pad - state_attrs[ATTR_LID_CLOSED] = lid_closed - state_attrs[ATTR_TANK_PRESENT] = tank_present - state_attrs[ATTR_TANK_LEVEL] = tank_level - - return state_attrs diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 10c3d36de12..d55a260e53a 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -2,62 +2,15 @@ from __future__ import annotations -import asyncio -import logging - -from homeassistant.components.vacuum import ( - ATTR_STATUS, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, - StateVacuumEntity, - VacuumEntityFeature, -) -from homeassistant.const import ATTR_CONNECTIONS, STATE_IDLE, STATE_PAUSED +from homeassistant.const import ATTR_CONNECTIONS import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -ATTR_CLEANING_TIME = "cleaning_time" -ATTR_CLEANED_AREA = "cleaned_area" -ATTR_ERROR = "error" -ATTR_ERROR_CODE = "error_code" -ATTR_POSITION = "position" -ATTR_SOFTWARE_VERSION = "software_version" - -# Commonly supported features -SUPPORT_IROBOT = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.SEND_COMMAND - | VacuumEntityFeature.START - | VacuumEntityFeature.STATE - | VacuumEntityFeature.STOP - | VacuumEntityFeature.LOCATE -) - -STATE_MAP = { - "": STATE_IDLE, - "charge": STATE_DOCKED, - "evac": STATE_RETURNING, # Emptying at cleanbase - "hmMidMsn": STATE_CLEANING, # Recharging at the middle of a cycle - "hmPostMsn": STATE_RETURNING, # Cycle finished - "hmUsrDock": STATE_RETURNING, - "pause": STATE_PAUSED, - "run": STATE_CLEANING, - "stop": STATE_IDLE, - "stuck": STATE_ERROR, -} - class IRobotEntity(Entity): """Base class for iRobot Entities.""" @@ -65,7 +18,7 @@ class IRobotEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, roomba, blid): + def __init__(self, roomba, blid) -> None: """Initialize the iRobot handler.""" self.vacuum = roomba self._blid = blid @@ -127,20 +80,6 @@ class IRobotEntity(Entity): return None return dt_util.utc_from_timestamp(ts) - @property - def _robot_state(self): - """Return the state of the vacuum cleaner.""" - clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) - cycle = clean_mission_status.get("cycle") - phase = clean_mission_status.get("phase") - try: - state = STATE_MAP[phase] - except KeyError: - return STATE_ERROR - if cycle != "none" and state in (STATE_IDLE, STATE_DOCKED): - state = STATE_PAUSED - return state - async def async_added_to_hass(self): """Register callback function.""" self.vacuum.register_on_message_callback(self.on_message) @@ -154,125 +93,3 @@ class IRobotEntity(Entity): state = json_data.get("state", {}).get("reported", {}) if self.new_state_filter(state): self.schedule_update_ha_state() - - -class IRobotVacuum(IRobotEntity, StateVacuumEntity): # pylint: disable=hass-enforce-class-module - """Base class for iRobot robots.""" - - _attr_name = None - _attr_supported_features = SUPPORT_IROBOT - _attr_available = True # Always available, otherwise setup will fail - - def __init__(self, roomba, blid): - """Initialize the iRobot handler.""" - super().__init__(roomba, blid) - self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - - @property - def state(self): - """Return the state of the vacuum cleaner.""" - return self._robot_state - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - state = self.vacuum_state - - # Roomba software version - software_version = state.get("softwareVer") - - # Set properties that are to appear in the GUI - state_attrs = {ATTR_SOFTWARE_VERSION: software_version} - - # Set legacy status to avoid break changes - state_attrs[ATTR_STATUS] = self.vacuum.current_state - - # Only add cleaning time and cleaned area attrs when the vacuum is - # currently on - if self.state == STATE_CLEANING: - # Get clean mission status - ( - state_attrs[ATTR_CLEANING_TIME], - state_attrs[ATTR_CLEANED_AREA], - ) = self.get_cleaning_status(state) - - # Error - if self.vacuum.error_code != 0: - state_attrs[ATTR_ERROR] = self.vacuum.error_message - state_attrs[ATTR_ERROR_CODE] = self.vacuum.error_code - - # Not all Roombas expose position data - # https://github.com/koalazak/dorita980/issues/48 - if self._cap_position: - pos_state = state.get("pose", {}) - position = None - pos_x = pos_state.get("point", {}).get("x") - pos_y = pos_state.get("point", {}).get("y") - theta = pos_state.get("theta") - if all(item is not None for item in (pos_x, pos_y, theta)): - position = f"({pos_x}, {pos_y}, {theta})" - state_attrs[ATTR_POSITION] = position - - return state_attrs - - def get_cleaning_status(self, state) -> tuple[int, int]: - """Return the cleaning time and cleaned area from the device.""" - if not (mission_state := state.get("cleanMissionStatus")): - return (0, 0) - - if cleaning_time := mission_state.get("mssnM", 0): - pass - elif start_time := mission_state.get("mssnStrtTm"): - now = dt_util.as_timestamp(dt_util.utcnow()) - if now > start_time: - cleaning_time = (now - start_time) // 60 - - if cleaned_area := mission_state.get("sqft", 0): # Imperial - # Convert to m2 if the unit_system is set to metric - if self.hass.config.units is METRIC_SYSTEM: - cleaned_area = round(cleaned_area * 0.0929) - - return (cleaning_time, cleaned_area) - - def on_message(self, json_data): - """Update state on message change.""" - state = json_data.get("state", {}).get("reported", {}) - if self.new_state_filter(state): - _LOGGER.debug("Got new state from the vacuum: %s", json_data) - self.schedule_update_ha_state() - - async def async_start(self): - """Start or resume the cleaning task.""" - if self.state == STATE_PAUSED: - await self.hass.async_add_executor_job(self.vacuum.send_command, "resume") - else: - await self.hass.async_add_executor_job(self.vacuum.send_command, "start") - - async def async_stop(self, **kwargs): - """Stop the vacuum cleaner.""" - await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") - - async def async_pause(self): - """Pause the cleaning cycle.""" - await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") - - async def async_return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - if self.state == STATE_CLEANING: - await self.async_pause() - for _ in range(10): - if self.state == STATE_PAUSED: - break - await asyncio.sleep(1) - await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") - - async def async_locate(self, **kwargs): - """Located vacuum.""" - await self.hass.async_add_executor_job(self.vacuum.send_command, "find") - - async def async_send_command(self, command, params=None, **kwargs): - """Send raw command.""" - _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) - await self.hass.async_add_executor_job( - self.vacuum.send_command, command, params - ) diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py deleted file mode 100644 index 917fbb2bfff..00000000000 --- a/homeassistant/components/roomba/roomba.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Class for Roomba devices.""" - -import logging - -from homeassistant.components.vacuum import VacuumEntityFeature - -from .entity import SUPPORT_IROBOT, IRobotVacuum - -_LOGGER = logging.getLogger(__name__) - -ATTR_BIN_FULL = "bin_full" -ATTR_BIN_PRESENT = "bin_present" - -FAN_SPEED_AUTOMATIC = "Automatic" -FAN_SPEED_ECO = "Eco" -FAN_SPEED_PERFORMANCE = "Performance" -FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] - -# Only Roombas with CarpetBost can set their fanspeed -SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED - - -class RoombaVacuum(IRobotVacuum): # pylint: disable=hass-enforce-class-module - """Basic Roomba robot (without carpet boost).""" - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - state_attrs = super().extra_state_attributes - - # Get bin state - bin_raw_state = self.vacuum_state.get("bin", {}) - bin_state = {} - if bin_raw_state.get("present") is not None: - bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present") - if bin_raw_state.get("full") is not None: - bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full") - state_attrs.update(bin_state) - - return state_attrs - - -class RoombaVacuumCarpetBoost(RoombaVacuum): # pylint: disable=hass-enforce-class-module - """Roomba robot with carpet boost.""" - - _attr_fan_speed_list = FAN_SPEEDS - _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - fan_speed = None - carpet_boost = self.vacuum_state.get("carpetBoost") - high_perf = self.vacuum_state.get("vacHigh") - if carpet_boost is not None and high_perf is not None: - if carpet_boost: - fan_speed = FAN_SPEED_AUTOMATIC - elif high_perf: - fan_speed = FAN_SPEED_PERFORMANCE - else: # carpet_boost and high_perf are False - fan_speed = FAN_SPEED_ECO - return fan_speed - - async def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - if fan_speed.capitalize() in FAN_SPEEDS: - fan_speed = fan_speed.capitalize() - _LOGGER.debug("Set fan speed to: %s", fan_speed) - high_perf = None - carpet_boost = None - if fan_speed == FAN_SPEED_AUTOMATIC: - high_perf = False - carpet_boost = True - elif fan_speed == FAN_SPEED_ECO: - high_perf = False - carpet_boost = False - elif fan_speed == FAN_SPEED_PERFORMANCE: - high_perf = True - carpet_boost = False - else: - _LOGGER.error("No such fan speed available: %s", fan_speed) - return - # The set_preference method does only accept string values - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "carpetBoost", str(carpet_boost) - ) - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "vacHigh", str(high_perf) - ) diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index a45b8eea632..92063f74afa 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -2,16 +2,88 @@ from __future__ import annotations +import asyncio +import logging +from typing import Any + +from homeassistant.components.vacuum import ( + ATTR_STATUS, + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state -from .braava import BraavaJet from .const import DOMAIN -from .entity import IRobotVacuum +from .entity import IRobotEntity from .models import RoombaData -from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost + +SUPPORT_IROBOT = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.START + | VacuumEntityFeature.STATE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.LOCATE +) + +STATE_MAP = { + "": VacuumActivity.IDLE, + "charge": VacuumActivity.DOCKED, + "evac": VacuumActivity.RETURNING, # Emptying at cleanbase + "hmMidMsn": VacuumActivity.CLEANING, # Recharging at the middle of a cycle + "hmPostMsn": VacuumActivity.RETURNING, # Cycle finished + "hmUsrDock": VacuumActivity.RETURNING, + "pause": VacuumActivity.PAUSED, + "run": VacuumActivity.CLEANING, + "stop": VacuumActivity.IDLE, + "stuck": VacuumActivity.ERROR, +} + +_LOGGER = logging.getLogger(__name__) +ATTR_SOFTWARE_VERSION = "software_version" +ATTR_CLEANING_TIME = "cleaning_time" +ATTR_CLEANED_AREA = "cleaned_area" +ATTR_ERROR = "error" +ATTR_ERROR_CODE = "error_code" +ATTR_POSITION = "position" +ATTR_SOFTWARE_VERSION = "software_version" + +ATTR_BIN_FULL = "bin_full" +ATTR_BIN_PRESENT = "bin_present" + +FAN_SPEED_AUTOMATIC = "Automatic" +FAN_SPEED_ECO = "Eco" +FAN_SPEED_PERFORMANCE = "Performance" +FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] + +# Only Roombas with CarpetBost can set their fanspeed +SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED + +ATTR_DETECTED_PAD = "detected_pad" +ATTR_LID_CLOSED = "lid_closed" +ATTR_TANK_PRESENT = "tank_present" +ATTR_TANK_LEVEL = "tank_level" +ATTR_PAD_WETNESS = "spray_amount" + +OVERLAP_STANDARD = 67 +OVERLAP_DEEP = 85 +OVERLAP_EXTENDED = 25 +MOP_STANDARD = "Standard" +MOP_DEEP = "Deep" +MOP_EXTENDED = "Extended" +BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED] +BRAAVA_SPRAY_AMOUNT = [1, 2, 3] + +# Braava Jets can set mopping behavior through fanspeed +SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED async def async_setup_entry( @@ -39,3 +111,304 @@ async def async_setup_entry( roomba_vac = constructor(roomba, blid) async_add_entities([roomba_vac]) + + +class IRobotVacuum(IRobotEntity, StateVacuumEntity): + """Base class for iRobot robots.""" + + _attr_name = None + _attr_supported_features = SUPPORT_IROBOT + _attr_available = True # Always available, otherwise setup will fail + + def __init__(self, roomba, blid) -> None: + """Initialize the iRobot handler.""" + super().__init__(roomba, blid) + self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 + + @property + def activity(self): + """Return the state of the vacuum cleaner.""" + clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) + cycle = clean_mission_status.get("cycle") + phase = clean_mission_status.get("phase") + try: + state = STATE_MAP[phase] + except KeyError: + return VacuumActivity.ERROR + if cycle != "none" and state in (VacuumActivity.IDLE, VacuumActivity.DOCKED): + state = VacuumActivity.PAUSED + return state + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + state = self.vacuum_state + + # Roomba software version + software_version = state.get("softwareVer") + + # Set properties that are to appear in the GUI + state_attrs = {ATTR_SOFTWARE_VERSION: software_version} + + # Set legacy status to avoid break changes + state_attrs[ATTR_STATUS] = self.vacuum.current_state + + # Only add cleaning time and cleaned area attrs when the vacuum is + # currently on + if self.state == VacuumActivity.CLEANING: + # Get clean mission status + ( + state_attrs[ATTR_CLEANING_TIME], + state_attrs[ATTR_CLEANED_AREA], + ) = self.get_cleaning_status(state) + + # Error + if self.vacuum.error_code != 0: + state_attrs[ATTR_ERROR] = self.vacuum.error_message + state_attrs[ATTR_ERROR_CODE] = self.vacuum.error_code + + # Not all Roombas expose position data + # https://github.com/koalazak/dorita980/issues/48 + if self._cap_position: + pos_state = state.get("pose", {}) + position = None + pos_x = pos_state.get("point", {}).get("x") + pos_y = pos_state.get("point", {}).get("y") + theta = pos_state.get("theta") + if all(item is not None for item in (pos_x, pos_y, theta)): + position = f"({pos_x}, {pos_y}, {theta})" + state_attrs[ATTR_POSITION] = position + + return state_attrs + + def get_cleaning_status(self, state) -> tuple[int, int]: + """Return the cleaning time and cleaned area from the device.""" + if not (mission_state := state.get("cleanMissionStatus")): + return (0, 0) + + if cleaning_time := mission_state.get("mssnM", 0): + pass + elif start_time := mission_state.get("mssnStrtTm"): + now = dt_util.as_timestamp(dt_util.utcnow()) + if now > start_time: + cleaning_time = (now - start_time) // 60 + + if cleaned_area := mission_state.get("sqft", 0): # Imperial + # Convert to m2 if the unit_system is set to metric + if self.hass.config.units is METRIC_SYSTEM: + cleaned_area = round(cleaned_area * 0.0929) + + return (cleaning_time, cleaned_area) + + def on_message(self, json_data): + """Update state on message change.""" + state = json_data.get("state", {}).get("reported", {}) + if self.new_state_filter(state): + _LOGGER.debug("Got new state from the vacuum: %s", json_data) + self.schedule_update_ha_state() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + if self.state == VacuumActivity.PAUSED: + await self.hass.async_add_executor_job(self.vacuum.send_command, "resume") + else: + await self.hass.async_add_executor_job(self.vacuum.send_command, "start") + + async def async_stop(self, **kwargs): + """Stop the vacuum cleaner.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") + + async def async_pause(self) -> None: + """Pause the cleaning cycle.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + if self.state == VacuumActivity.CLEANING: + await self.async_pause() + for _ in range(10): + if self.state == VacuumActivity.PAUSED: + break + await asyncio.sleep(1) + await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") + + async def async_locate(self, **kwargs): + """Located vacuum.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "find") + + async def async_send_command(self, command, params=None, **kwargs): + """Send raw command.""" + _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) + await self.hass.async_add_executor_job( + self.vacuum.send_command, command, params + ) + + +class RoombaVacuum(IRobotVacuum): + """Basic Roomba robot (without carpet boost).""" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + state_attrs = super().extra_state_attributes + + # Get bin state + bin_raw_state = self.vacuum_state.get("bin", {}) + bin_state = {} + if bin_raw_state.get("present") is not None: + bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present") + if bin_raw_state.get("full") is not None: + bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full") + state_attrs.update(bin_state) + + return state_attrs + + +class RoombaVacuumCarpetBoost(RoombaVacuum): + """Roomba robot with carpet boost.""" + + _attr_fan_speed_list = FAN_SPEEDS + _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + fan_speed = None + carpet_boost = self.vacuum_state.get("carpetBoost") + high_perf = self.vacuum_state.get("vacHigh") + if carpet_boost is not None and high_perf is not None: + if carpet_boost: + fan_speed = FAN_SPEED_AUTOMATIC + elif high_perf: + fan_speed = FAN_SPEED_PERFORMANCE + else: # carpet_boost and high_perf are False + fan_speed = FAN_SPEED_ECO + return fan_speed + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if fan_speed.capitalize() in FAN_SPEEDS: + fan_speed = fan_speed.capitalize() + _LOGGER.debug("Set fan speed to: %s", fan_speed) + high_perf = None + carpet_boost = None + if fan_speed == FAN_SPEED_AUTOMATIC: + high_perf = False + carpet_boost = True + elif fan_speed == FAN_SPEED_ECO: + high_perf = False + carpet_boost = False + elif fan_speed == FAN_SPEED_PERFORMANCE: + high_perf = True + carpet_boost = False + else: + _LOGGER.error("No such fan speed available: %s", fan_speed) + return + # The set_preference method does only accept string values + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "carpetBoost", str(carpet_boost) + ) + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "vacHigh", str(high_perf) + ) + + +class BraavaJet(IRobotVacuum): + """Braava Jet.""" + + _attr_supported_features = SUPPORT_BRAAVA + + def __init__(self, roomba, blid) -> None: + """Initialize the Roomba handler.""" + super().__init__(roomba, blid) + + # Initialize fan speed list + self._attr_fan_speed_list = [ + f"{behavior}-{spray}" + for behavior in BRAAVA_MOP_BEHAVIORS + for spray in BRAAVA_SPRAY_AMOUNT + ] + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + # Mopping behavior and spray amount as fan speed + rank_overlap = self.vacuum_state.get("rankOverlap", {}) + behavior = None + if rank_overlap == OVERLAP_STANDARD: + behavior = MOP_STANDARD + elif rank_overlap == OVERLAP_DEEP: + behavior = MOP_DEEP + elif rank_overlap == OVERLAP_EXTENDED: + behavior = MOP_EXTENDED + pad_wetness = self.vacuum_state.get("padWetness", {}) + # "disposable" and "reusable" values are always the same + pad_wetness_value = pad_wetness.get("disposable") + return f"{behavior}-{pad_wetness_value}" + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + try: + split = fan_speed.split("-", 1) + behavior = split[0] + spray = int(split[1]) + if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: + behavior = behavior.capitalize() + except IndexError: + _LOGGER.error( + "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", + fan_speed, + ) + return + except ValueError: + _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1]) + return + if behavior not in BRAAVA_MOP_BEHAVIORS: + _LOGGER.error( + "Mop behavior error: expected one of %s, got '%s'", + str(BRAAVA_MOP_BEHAVIORS), + behavior, + ) + return + if spray not in BRAAVA_SPRAY_AMOUNT: + _LOGGER.error( + "Spray amount error: expected one of %s, got '%d'", + str(BRAAVA_SPRAY_AMOUNT), + spray, + ) + return + + overlap = 0 + if behavior == MOP_STANDARD: + overlap = OVERLAP_STANDARD + elif behavior == MOP_DEEP: + overlap = OVERLAP_DEEP + else: + overlap = OVERLAP_EXTENDED + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "rankOverlap", overlap + ) + await self.hass.async_add_executor_job( + self.vacuum.set_preference, + "padWetness", + {"disposable": spray, "reusable": spray}, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + state_attrs = super().extra_state_attributes + + # Get Braava state + state = self.vacuum_state + detected_pad = state.get("detectedPad") + mop_ready = state.get("mopReady", {}) + lid_closed = mop_ready.get("lidClosed") + tank_present = mop_ready.get("tankPresent") + tank_level = state.get("tankLvl") + state_attrs[ATTR_DETECTED_PAD] = detected_pad + state_attrs[ATTR_LID_CLOSED] = lid_closed + state_attrs[ATTR_TANK_PRESENT] = tank_present + state_attrs[ATTR_TANK_LEVEL] = tank_level + + return state_attrs diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 784629ea0bc..b068fbd1892 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.disconnect() diff --git a/homeassistant/components/russound_rio/diagnostics.py b/homeassistant/components/russound_rio/diagnostics.py new file mode 100644 index 00000000000..0e96413c41a --- /dev/null +++ b/homeassistant/components/russound_rio/diagnostics.py @@ -0,0 +1,14 @@ +"""Diagnostics platform for Russound RIO.""" + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import RussoundConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: RussoundConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the provided config entry.""" + return entry.runtime_data.state diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 45818d3e25b..02467731ec3 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -3,10 +3,13 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING from aiorussound import Controller +from aiorussound.const import FeatureFlag from aiorussound.models import PlayStatus, Source from aiorussound.rio import ZoneControlSurface +from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -28,6 +31,8 @@ from .entity import RussoundBaseEntity, command _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_platform( hass: HomeAssistant, @@ -146,37 +151,50 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): return MediaPlayerState.ON @property - def source(self): + def source(self) -> str: """Get the currently selected source.""" return self._source.name @property - def source_list(self): + def source_list(self) -> list[str]: """Return a list of available input sources.""" - return [x.name for x in self._sources.values()] + if TYPE_CHECKING: + assert self._client.rio_version + available_sources = ( + [ + source + for source_id, source in self._sources.items() + if source_id in self._zone.enabled_sources + ] + if is_feature_supported( + self._client.rio_version, FeatureFlag.SUPPORT_ZONE_SOURCE_EXCLUSION + ) + else self._sources.values() + ) + return [x.name for x in available_sources] @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" return self._source.song_name @property - def media_artist(self): + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._source.artist_name @property - def media_album_name(self): + def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._source.album_name @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._source.cover_art_url @property - def volume_level(self): + def volume_level(self) -> float: """Volume level of the media player (0..1). Value is returned based on a range (0..50). diff --git a/homeassistant/components/russound_rio/quality_scale.yaml b/homeassistant/components/russound_rio/quality_scale.yaml new file mode 100644 index 00000000000..3a5e8f9adb7 --- /dev/null +++ b/homeassistant/components/russound_rio/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration uses a push API. No polling required. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + The data_description fields in translations are missing. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: todo + 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 + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: todo + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + # Gold + entity-translations: + status: exempt + comment: | + There are no entities to translate. + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: | + This integration doesn't have enough / noisy entities that warrant being disabled by default. + discovery: todo + stale-devices: todo + diagnostics: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: done + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration uses telnet exclusively and does not make http calls. + strict-typing: done diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index e6a99c858c3..2e3d6dd613c 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_API_KEY, @@ -48,6 +49,8 @@ SERVICE_SPEED_SCHEMA = SERVICE_BASE_SCHEMA.extend( } ) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + @callback def async_get_entry_for_service_call( @@ -63,17 +66,9 @@ def async_get_entry_for_service_call( raise ValueError(f"No api for API key: {call_data_api_key}") -async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SabNzbd Component.""" - sab_api = await get_client(hass, entry.data) - if not sab_api: - raise ConfigEntryNotReady - - coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator - @callback def extract_api( func: Callable[ @@ -147,11 +142,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> b (SERVICE_RESUME, async_resume_queue, SERVICE_BASE_SCHEMA), (SERVICE_SET_SPEED, async_set_queue_speed, SERVICE_SPEED_SCHEMA), ): - if hass.services.has_service(DOMAIN, service): - continue - hass.services.async_register(DOMAIN, service, method, schema=schema) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: + """Set up the SabNzbd Component.""" + + sab_api = await get_client(hass, entry.data) + if not sab_api: + raise ConfigEntryNotReady + + coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 041e9b8fe9b..1a6b5ed5313 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -37,7 +37,7 @@ "requirements": [ "getmac==0.9.4", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.7.1", + "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", "async-upnp-client==0.41.0" ], diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py index 6f0a49e6eb9..7db15d3923c 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -82,7 +82,6 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, serial_number, api, session_id): """Initialize the thermostat.""" diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 4d93dcf81d3..08300900f5d 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -80,7 +80,6 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 390ebc080b8..5bf455c3631 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from bisect import bisect_left -from typing import TYPE_CHECKING, Any +from typing import Any import voluptuous as vol @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -108,7 +108,7 @@ AC_STATE_TO_DATA = { } -def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int: +def _find_valid_target_temp(target: float, valid_targets: list[int]) -> int: if target <= valid_targets[0]: return valid_targets[0] if target >= valid_targets[-1]: @@ -194,7 +194,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): _attr_name = None _attr_precision = PRECISION_TENTHS _attr_translation_key = "climate_device" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str @@ -232,10 +231,9 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" - if TYPE_CHECKING: - assert self.device_data.hvac_modes - hvac_modes = [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] - return hvac_modes if hvac_modes else [HVACMode.OFF] + if not self.device_data.hvac_modes: + return [HVACMode.OFF] + return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] @property def current_temperature(self) -> float | None: @@ -260,52 +258,42 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - target_temp: int | None = self.device_data.target_temp - return target_temp + return self.device_data.target_temp @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - target_temp_step: int = self.device_data.temp_step - return target_temp_step + return self.device_data.temp_step @property def fan_mode(self) -> str | None: """Return the fan setting.""" - fan_mode: str | None = self.device_data.fan_mode - return fan_mode + return self.device_data.fan_mode @property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes.""" - if self.device_data.fan_modes: - return self.device_data.fan_modes - return None + return self.device_data.fan_modes @property def swing_mode(self) -> str | None: """Return the swing setting.""" - swing_mode: str | None = self.device_data.swing_mode - return swing_mode + return self.device_data.swing_mode @property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - if self.device_data.swing_modes: - return self.device_data.swing_modes - return None + return self.device_data.swing_modes @property def min_temp(self) -> float: """Return the minimum temperature.""" - min_temp: int = self.device_data.temp_list[0] - return min_temp + return self.device_data.temp_list[0] @property def max_temp(self) -> float: """Return the maximum temperature.""" - max_temp: int = self.device_data.temp_list[-1] - return max_temp + return self.device_data.temp_list[-1] @property def available(self) -> bool: @@ -320,12 +308,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): translation_key="no_target_temperature_in_features", ) - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_target_temperature", - ) - + temperature: float = kwargs[ATTR_TEMPERATURE] if temperature == self.target_temperature: return diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index bec402bee18..302e34bb5aa 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -500,9 +500,6 @@ "no_target_temperature_in_features": { "message": "Current mode doesn't support setting target temperature" }, - "no_target_temperature": { - "message": "No target temperature provided" - }, "no_fan_level_in_features": { "message": "Current mode doesn't support setting fan level" }, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 4d0454cbff3..2fb563051a9 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -191,7 +191,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy consumption, for example electric energy consumption. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" @@ -200,7 +200,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ FREQUENCY = "frequency" @@ -561,7 +561,13 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, - SensorDeviceClass.POWER: {UnitOfPower.WATT, UnitOfPower.KILO_WATT}, + SensorDeviceClass.POWER: { + UnitOfPower.WATT, + UnitOfPower.KILO_WATT, + UnitOfPower.MEGA_WATT, + UnitOfPower.GIGA_WATT, + UnitOfPower.TERA_WATT, + }, SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), SensorDeviceClass.PRESSURE: set(UnitOfPressure), diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index 3b834654ca6..d5749a3f040 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -46,7 +46,6 @@ class SENZClimate(CoordinatorEntity, ClimateEntity): _attr_min_temp = 5 _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 997d229e6b9..873d3fbd290 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -9,12 +9,8 @@ from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -30,10 +26,10 @@ from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK from .coordinator import SharkIqUpdateCoordinator OPERATING_STATE_MAP = { - OperatingModes.PAUSE: STATE_PAUSED, - OperatingModes.START: STATE_CLEANING, - OperatingModes.STOP: STATE_IDLE, - OperatingModes.RETURN: STATE_RETURNING, + OperatingModes.PAUSE: VacuumActivity.PAUSED, + OperatingModes.START: VacuumActivity.CLEANING, + OperatingModes.STOP: VacuumActivity.IDLE, + OperatingModes.RETURN: VacuumActivity.RETURNING, } FAN_SPEEDS_MAP = { @@ -156,7 +152,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum return self.sharkiq.get_property_value(Properties.RECHARGING_TO_RESUME) @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Get the current vacuum state. NB: Currently, we do not return an error state because they can be very, very stale. @@ -164,7 +160,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum user a notification. """ if self.sharkiq.get_property_value(Properties.CHARGING_STATUS): - return STATE_DOCKED + return VacuumActivity.DOCKED op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE) return OPERATING_STATE_MAP.get(op_mode) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index b77f45afb3f..842abc5ecc4 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -172,7 +172,6 @@ class BlockSleepingClimate( ) _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -456,7 +455,6 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): ) _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize.""" diff --git a/homeassistant/components/simulated/__init__.py b/homeassistant/components/simulated/__init__.py deleted file mode 100644 index 35c6d106d03..00000000000 --- a/homeassistant/components/simulated/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The simulated component.""" diff --git a/homeassistant/components/simulated/manifest.json b/homeassistant/components/simulated/manifest.json deleted file mode 100644 index e76bf142086..00000000000 --- a/homeassistant/components/simulated/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "simulated", - "name": "Simulated", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/simulated", - "iot_class": "local_polling", - "quality_scale": "internal" -} diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py deleted file mode 100644 index 22ce4bd7cea..00000000000 --- a/homeassistant/components/simulated/sensor.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Adds a simulated sensor.""" - -from __future__ import annotations - -from datetime import datetime -import math -from random import Random - -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util - -CONF_AMP = "amplitude" -CONF_FWHM = "spread" -CONF_MEAN = "mean" -CONF_PERIOD = "period" -CONF_PHASE = "phase" -CONF_SEED = "seed" -CONF_UNIT = "unit" -CONF_RELATIVE_TO_EPOCH = "relative_to_epoch" - -DEFAULT_AMP = 1 -DEFAULT_FWHM = 0 -DEFAULT_MEAN = 0 -DEFAULT_NAME = "simulated" -DEFAULT_PERIOD = 60 -DEFAULT_PHASE = 0 -DEFAULT_SEED = 999 -DEFAULT_UNIT = "value" -DEFAULT_RELATIVE_TO_EPOCH = True - -DOMAIN = "simulated" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), - vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), - vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, - vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float), - vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int, - vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, - vol.Optional( - CONF_RELATIVE_TO_EPOCH, default=DEFAULT_RELATIVE_TO_EPOCH - ): cv.boolean, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the simulated sensor.""" - # Simulated has been deprecated and will be removed in 2025.1 - - ir.async_create_issue( - hass, - DOMAIN, - DOMAIN, - breaks_in_ha_version="2025.1.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="simulated_deprecation", - translation_placeholders={"integration": DOMAIN}, - learn_more_url="https://www.home-assistant.io/integrations/simulated", - ) - - name = config.get(CONF_NAME) - unit = config.get(CONF_UNIT) - amp = config.get(CONF_AMP) - mean = config.get(CONF_MEAN) - period = config.get(CONF_PERIOD) - phase = config.get(CONF_PHASE) - fwhm = config.get(CONF_FWHM) - seed = config.get(CONF_SEED) - relative_to_epoch = config.get(CONF_RELATIVE_TO_EPOCH) - - sensor = SimulatedSensor( - name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch - ) - async_add_entities([sensor], True) - - -class SimulatedSensor(SensorEntity): - """Class for simulated sensor.""" - - _attr_icon = "mdi:chart-line" - - def __init__( - self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch - ): - """Init the class.""" - self._name = name - self._unit = unit - self._amp = amp - self._mean = mean - self._period = period - self._phase = phase # phase in degrees - self._fwhm = fwhm - self._seed = seed - self._random = Random(seed) # A local seeded Random - self._start_time = ( - datetime(1970, 1, 1, tzinfo=dt_util.UTC) - if relative_to_epoch - else dt_util.utcnow() - ) - self._relative_to_epoch = relative_to_epoch - self._state = None - - def time_delta(self): - """Return the time delta.""" - dt0 = self._start_time - dt1 = dt_util.utcnow() - return dt1 - dt0 - - def signal_calc(self): - """Calculate the signal.""" - mean = self._mean - amp = self._amp - time_delta = self.time_delta().total_seconds() * 1e6 # to milliseconds - period = self._period * 1e6 # to milliseconds - fwhm = self._fwhm / 2 - phase = math.radians(self._phase) - if period == 0: - periodic = 0 - else: - periodic = amp * (math.sin((2 * math.pi * time_delta / period) + phase)) - noise = self._random.gauss(mu=0, sigma=fwhm) - return round(mean + periodic + noise, 3) - - async def async_update(self) -> None: - """Update the sensor.""" - self._state = self.signal_calc() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit - - @property - def extra_state_attributes(self): - """Return other details about the sensor state.""" - return { - "amplitude": self._amp, - "mean": self._mean, - "period": self._period, - "phase": self._phase, - "spread": self._fwhm, - "seed": self._seed, - "relative_to_epoch": self._relative_to_epoch, - } diff --git a/homeassistant/components/simulated/strings.json b/homeassistant/components/simulated/strings.json deleted file mode 100644 index d25a84f48a5..00000000000 --- a/homeassistant/components/simulated/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "simulated_deprecation": { - "description": "The {integration} integration is deprecated", - "title": "The {integration} integration has been deprecated and will be removed in 2025.1. Please remove the {integration} from your configuration.yaml settings and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 8fab0dfe96d..9ce6898fd93 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -191,9 +191,4 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" - features = self._attr_supported_features - if type(features) is int: # noqa: E721 - new_features = SirenEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features + return self._attr_supported_features diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py new file mode 100644 index 00000000000..5b4867bf337 --- /dev/null +++ b/homeassistant/components/slide_local/__init__.py @@ -0,0 +1,38 @@ +"""Component for the Slide local API.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SlideCoordinator + +PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SWITCH] +type SlideConfigEntry = ConfigEntry[SlideCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: + """Set up the slide_local integration.""" + + coordinator = SlideCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py new file mode 100644 index 00000000000..9c285881116 --- /dev/null +++ b/homeassistant/components/slide_local/button.py @@ -0,0 +1,42 @@ +"""Support for Slide button.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities([SlideButton(coordinator)]) + + +class SlideButton(SlideEntity, ButtonEntity): + """Defines a Slide button.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "calibrate" + + def __init__(self, coordinator: SlideCoordinator) -> None: + """Initialize the slide button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data["mac"]}-calibrate" + + async def async_press(self) -> None: + """Send out a calibrate command.""" + await self.coordinator.slide.slide_calibrate(self.coordinator.host) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py new file mode 100644 index 00000000000..3ccc89be375 --- /dev/null +++ b/homeassistant/components/slide_local/config_flow.py @@ -0,0 +1,216 @@ +"""Config flow for slide_local integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, + GoSlideLocal as SlideLocalApi, +) +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac + +from . import SlideConfigEntry +from .const import CONF_INVERT_POSITION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SlideConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for slide_local.""" + + _mac: str = "" + _host: str = "" + _api_version: int | None = None + + VERSION = 1 + MINOR_VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: SlideConfigEntry, + ) -> SlideOptionsFlowHandler: + """Get the options flow for this handler.""" + return SlideOptionsFlowHandler() + + async def async_test_connection( + self, user_input: dict[str, str | int] + ) -> dict[str, str]: + """Reusable Auth Helper.""" + slide = SlideLocalApi() + + # first test, if API version 2 is working + await slide.slide_add( + user_input[CONF_HOST], + user_input.get(CONF_PASSWORD, ""), + 2, + ) + + try: + result = await slide.slide_info(user_input[CONF_HOST]) + except (ClientConnectionError, ClientTimeoutError): + return {"base": "cannot_connect"} + except (AuthenticationFailed, DigestAuthCalcError): + return {"base": "invalid_auth"} + except Exception: # noqa: BLE001 + _LOGGER.exception("Exception occurred during connection test") + return {"base": "unknown"} + + if result is not None: + self._api_version = 2 + self._mac = format_mac(result["mac"]) + return {} + + # API version 2 is not working, try API version 1 instead + await slide.slide_del(user_input[CONF_HOST]) + await slide.slide_add( + user_input[CONF_HOST], + user_input.get(CONF_PASSWORD, ""), + 1, + ) + + try: + result = await slide.slide_info(user_input[CONF_HOST]) + except (ClientConnectionError, ClientTimeoutError): + return {"base": "cannot_connect"} + except (AuthenticationFailed, DigestAuthCalcError): + return {"base": "invalid_auth"} + except Exception: # noqa: BLE001 + _LOGGER.exception("Exception occurred during connection test") + return {"base": "unknown"} + + if result is None: + # API version 1 isn't working either + return {"base": "unknown"} + + self._api_version = 1 + self._mac = format_mac(result["mac"]) + + return {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors = {} + if user_input is not None: + if not (errors := await self.async_test_connection(user_input)): + await self.async_set_unique_id(self._mac) + self._abort_if_unique_id_configured() + user_input |= { + CONF_MAC: self._mac, + CONF_API_VERSION: self._api_version, + } + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + options={CONF_INVERT_POSITION: False}, + ) + + if user_input is not None and user_input.get(CONF_HOST) is not None: + self._host = user_input[CONF_HOST] + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + {CONF_HOST: self._host}, + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # id is in the format 'slide_000000000000' + self._mac = format_mac(str(discovery_info.properties.get("id"))[6:]) + + await self.async_set_unique_id(self._mac) + + self._abort_if_unique_id_configured( + {CONF_HOST: discovery_info.host}, reload_on_update=True + ) + + errors = {} + if errors := await self.async_test_connection( + { + CONF_HOST: self._host, + } + ): + return self.async_abort( + reason="discovery_connection_failed", + description_placeholders={ + "error": errors["base"], + }, + ) + + self._host = discovery_info.host + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + + if user_input is not None: + user_input |= { + CONF_HOST: self._host, + CONF_API_VERSION: 2, + CONF_MAC: format_mac(self._mac), + } + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + options={CONF_INVERT_POSITION: False}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "host": self._host, + }, + ) + + +class SlideOptionsFlowHandler(OptionsFlow): + """Handle a options flow for slide_local.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_INVERT_POSITION): bool, + } + ), + {CONF_INVERT_POSITION: self.config_entry.options[CONF_INVERT_POSITION]}, + ), + ) diff --git a/homeassistant/components/slide_local/const.py b/homeassistant/components/slide_local/const.py new file mode 100644 index 00000000000..9dc6d4ac925 --- /dev/null +++ b/homeassistant/components/slide_local/const.py @@ -0,0 +1,13 @@ +"""Define constants for the Slide component.""" + +API_LOCAL = "api_local" +ATTR_TOUCHGO = "touchgo" +CONF_INVERT_POSITION = "invert_position" +CONF_VERIFY_SSL = "verify_ssl" +DOMAIN = "slide_local" +SLIDES = "slides" +SLIDES_LOCAL = "slides_local" +DEFAULT_OFFSET = 0.15 +DEFAULT_RETRY = 120 +SERVICE_CALIBRATE = "calibrate" +SERVICE_TOUCHGO = "touchgo" diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py new file mode 100644 index 00000000000..e5311967198 --- /dev/null +++ b/homeassistant/components/slide_local/coordinator.py @@ -0,0 +1,112 @@ +"""DataUpdateCoordinator for slide_local integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, + GoSlideLocal as SlideLocalApi, +) + +from homeassistant.const import ( + CONF_API_VERSION, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_OFFSET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +if TYPE_CHECKING: + from . import SlideConfigEntry + + +class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15) + ) + self.slide = SlideLocalApi() + self.api_version = entry.data[CONF_API_VERSION] + self.mac = entry.data[CONF_MAC] + self.host = entry.data[CONF_HOST] + self.password = entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + + async def _async_setup(self) -> None: + """Do initialization logic for Slide coordinator.""" + _LOGGER.debug("Initializing Slide coordinator") + + await self.slide.slide_add( + self.host, + self.password, + self.api_version, + ) + + _LOGGER.debug("Slide coordinator initialized") + + async def _async_update_data(self) -> dict[str, Any]: + """Update the data from the Slide device.""" + _LOGGER.debug("Start data update") + + try: + data = await self.slide.slide_info(self.host) + except ( + ClientConnectionError, + AuthenticationFailed, + ClientTimeoutError, + DigestAuthCalcError, + ) as ex: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + ) from ex + + if data is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + ) + + if "pos" in data: + if self.data is None: + oldpos = None + else: + oldpos = self.data.get("pos") + + data["pos"] = max(0, min(1, data["pos"])) + + if oldpos is None or oldpos == data["pos"]: + data["state"] = ( + STATE_CLOSED if data["pos"] > (1 - DEFAULT_OFFSET) else STATE_OPEN + ) + elif oldpos < data["pos"]: + data["state"] = ( + STATE_CLOSED + if data["pos"] >= (1 - DEFAULT_OFFSET) + else STATE_CLOSING + ) + else: + data["state"] = ( + STATE_OPEN if data["pos"] <= DEFAULT_OFFSET else STATE_OPENING + ) + + _LOGGER.debug("Data successfully updated: %s", data) + + return data diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py new file mode 100644 index 00000000000..cf04f46d139 --- /dev/null +++ b/homeassistant/components/slide_local/cover.py @@ -0,0 +1,113 @@ +"""Support for Slide covers.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up cover(s) for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities( + [ + SlideCoverLocal( + coordinator, + entry, + ) + ] + ) + + +class SlideCoverLocal(SlideEntity, CoverEntity): + """Representation of a Slide Local API cover.""" + + _attr_assumed_state = True + _attr_device_class = CoverDeviceClass.CURTAIN + + def __init__( + self, + coordinator: SlideCoordinator, + entry: SlideConfigEntry, + ) -> None: + """Initialize the cover.""" + super().__init__(coordinator) + + self._attr_name = None + self.invert = entry.options[CONF_INVERT_POSITION] + self._attr_unique_id = coordinator.data["mac"] + + @property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self.coordinator.data["state"] == STATE_OPENING + + @property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self.coordinator.data["state"] == STATE_CLOSING + + @property + def is_closed(self) -> bool: + """Return None if status is unknown, True if closed, else False.""" + return self.coordinator.data["state"] == STATE_CLOSED + + @property + def current_cover_position(self) -> int | None: + """Return the current position of cover shutter.""" + pos = self.coordinator.data["pos"] + if pos is not None: + if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET: + pos = round(pos) + if not self.invert: + pos = 1 - pos + pos = int(pos * 100) + return pos + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + self.coordinator.data["state"] = STATE_OPENING + await self.coordinator.slide.slide_open(self.coordinator.host) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + self.coordinator.data["state"] = STATE_CLOSING + await self.coordinator.slide.slide_close(self.coordinator.host) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.coordinator.slide.slide_stop(self.coordinator.host) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] / 100 + if not self.invert: + position = 1 - position + + if self.coordinator.data["pos"] is not None: + if position > self.coordinator.data["pos"]: + self.coordinator.data["state"] = STATE_CLOSING + else: + self.coordinator.data["state"] = STATE_OPENING + + await self.coordinator.slide.slide_set_position(self.coordinator.host, position) diff --git a/homeassistant/components/slide_local/entity.py b/homeassistant/components/slide_local/entity.py new file mode 100644 index 00000000000..51269649add --- /dev/null +++ b/homeassistant/components/slide_local/entity.py @@ -0,0 +1,27 @@ +"""Entities for slide_local integration.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import SlideCoordinator + + +class SlideEntity(CoordinatorEntity[SlideCoordinator]): + """Base class of a Slide local API cover.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SlideCoordinator) -> None: + """Initialize the Slide device.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + manufacturer="Innovation in Motion", + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data["mac"])}, + name=coordinator.data["device_name"], + sw_version=coordinator.api_version, + hw_version=coordinator.data["board_rev"], + serial_number=coordinator.data["mac"], + configuration_url=f"http://{coordinator.host}", + ) diff --git a/homeassistant/components/slide_local/icons.json b/homeassistant/components/slide_local/icons.json new file mode 100644 index 00000000000..70d53e7f7a3 --- /dev/null +++ b/homeassistant/components/slide_local/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "calibrate": { + "default": "mdi:tape-measure" + } + } + } +} diff --git a/homeassistant/components/slide_local/manifest.json b/homeassistant/components/slide_local/manifest.json new file mode 100644 index 00000000000..42c74b2c308 --- /dev/null +++ b/homeassistant/components/slide_local/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "slide_local", + "name": "Slide Local", + "codeowners": ["@dontinelli"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/slide_local", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["goslide-api==0.7.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "slide*" + } + ] +} diff --git a/homeassistant/components/slide_local/quality_scale.yaml b/homeassistant/components/slide_local/quality_scale.yaml new file mode 100644 index 00000000000..4eda62f6497 --- /dev/null +++ b/homeassistant/components/slide_local/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: No explicit event subscriptions. + dependency-transparency: done + action-setup: done + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No custom action. + reauthentication-flow: todo + parallel-updates: done + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: todo + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: todo + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + This integration doesn't have known issues that could be resolved by the user. + docs-examples: done + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json new file mode 100644 index 00000000000..24c03d2ff96 --- /dev/null +++ b/homeassistant/components/slide_local/strings.json @@ -0,0 +1,61 @@ +{ + "config": { + "step": { + "user": { + "description": "Provide information to connect to the Slide device", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your local Slide", + "password": "The device code of your Slide (inside of the Slide or in the box, length is 8 characters). If your Slide runs firmware version 2 this is optional, as it is not used by the local API." + } + }, + "zeroconf_confirm": { + "title": "Confirm setup for Slide", + "description": "Do you want to setup {host}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "discovery_connection_failed": "The setup of the discovered device failed with the following error: {error}. Please try to set it up manually." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure Slide", + "description": "Reconfigure the Slide device", + "data": { + "invert_position": "Invert position" + }, + "data_description": { + "invert_position": "Invert the position of your slide cover." + } + } + } + }, + "entity": { + "button": { + "calibrate": { + "name": "Calibrate" + } + }, + "switch": { + "touchgo": { + "name": "TouchGo" + } + } + }, + "exceptions": { + "update_error": { + "message": "Error while updating data from the API." + } + } +} diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py new file mode 100644 index 00000000000..6d357864c48 --- /dev/null +++ b/homeassistant/components/slide_local/switch.py @@ -0,0 +1,56 @@ +"""Support for Slide switch.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities([SlideSwitch(coordinator)]) + + +class SlideSwitch(SlideEntity, SwitchEntity): + """Defines a Slide switch.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "touchgo" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, coordinator: SlideCoordinator) -> None: + """Initialize the slide switch.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data["mac"]}-touchgo" + + @property + def is_on(self) -> bool: + """Return if switch is on.""" + return self.coordinator.data["touch_go"] + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off touchgo.""" + await self.coordinator.slide.slide_set_touchgo(self.coordinator.host, False) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on touchgo.""" + await self.coordinator.slide.slide_set_touchgo(self.coordinator.host, True) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 073a1470c21..d9535272295 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -164,8 +164,6 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, device): """Init the class.""" super().__init__(device) @@ -347,7 +345,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _hvac_modes: list[HVACMode] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device) -> None: """Init the class.""" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 131cccdd869..61e30589273 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -70,7 +70,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Init the class.""" diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index fd4b87f0ee7..eb7c9af246b 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -10,7 +10,7 @@ from pysmartthings import Capability from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ColorMode, @@ -21,7 +21,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -79,12 +78,12 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): # SmartThings does not expose this attribute, instead it's # implemented within each device-type handler. This value is the # lowest kelvin found supported across 20+ handlers. - _attr_max_mireds = 500 # 2000K + _attr_min_color_temp_kelvin = 2000 # 500 mireds # SmartThings does not expose this attribute, instead it's # implemented within each device-type handler. This value is the # highest kelvin found supported across 20+ handlers. - _attr_min_mireds = 111 # 9000K + _attr_max_color_temp_kelvin = 9000 # 111 mireds def __init__(self, device): """Initialize a SmartThingsLight.""" @@ -122,8 +121,8 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): """Turn the light on.""" tasks = [] # Color temperature - if ATTR_COLOR_TEMP in kwargs: - tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP])) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN])) # Color if ATTR_HS_COLOR in kwargs: tasks.append(self.async_set_color(kwargs[ATTR_HS_COLOR])) @@ -164,9 +163,7 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._attr_color_temp = color_util.color_temperature_kelvin_to_mired( - self._device.status.color_temperature - ) + self._attr_color_temp_kelvin = self._device.status.color_temperature # Color if ColorMode.HS in self._attr_supported_color_modes: self._attr_hs_color = ( @@ -181,10 +178,9 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): saturation = max(min(float(hs_color[1]), 100.0), 0.0) await self._device.set_color(hue, saturation, set_status=True) - async def async_set_color_temp(self, value: float): + async def async_set_color_temp(self, value: int): """Set the color temperature of the device.""" - kelvin = color_util.color_temperature_mired_to_kelvin(value) - kelvin = max(min(kelvin, 30000), 1) + kelvin = max(min(value, 30000), 1) await self._device.set_color_temperature(kelvin, set_status=True) async def async_set_level(self, brightness: int, transition: int): diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index f0bb84b3390..7f3163834e0 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -68,7 +68,6 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = list(PRESET_MODES.values()) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, spa): """Initialize the entity.""" diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 378585a33e1..2804f14ee15 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -48,7 +48,6 @@ class SmartyFan(SmartyEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: SmartyCoordinator) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index a4163355944..b853535b525 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1,37 +1,28 @@ """Snapcast Integration.""" -import logging - -import snapcast.control - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, PLATFORMS -from .server import HomeAssistantSnapcast - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SnapcastUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Snapcast from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] + coordinator = SnapcastUpdateCoordinator(hass, host, port) + try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) + await coordinator.async_config_entry_first_refresh() except OSError as ex: raise ConfigEntryNotReady( f"Could not connect to Snapcast server at {host}:{port}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast( - hass, server, f"{host}:{port}", entry.entry_id - ) - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py new file mode 100644 index 00000000000..5bb9ae4e51f --- /dev/null +++ b/homeassistant/components/snapcast/coordinator.py @@ -0,0 +1,72 @@ +"""Data update coordinator for Snapcast server.""" + +from __future__ import annotations + +import logging + +from snapcast.control.server import Snapserver + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): + """Data update coordinator for pushed data from Snapcast server.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=f"{host}:{port}", + update_interval=None, # Disable update interval as server pushes + ) + + self._server = Snapserver(hass.loop, host, port, True) + self.last_update_success = False + + self._server.set_on_update_callback(self._on_update) + self._server.set_new_client_callback(self._on_update) + self._server.set_on_connect_callback(self._on_connect) + self._server.set_on_disconnect_callback(self._on_disconnect) + + def _on_update(self) -> None: + """Snapserver on_update callback.""" + # Assume availability if an update is received. + self.last_update_success = True + self.async_update_listeners() + + def _on_connect(self) -> None: + """Snapserver on_connect callback.""" + self.last_update_success = True + self.async_update_listeners() + + def _on_disconnect(self, ex): + """Snapsever on_disconnect callback.""" + self.async_set_update_error(ex) + + async def _async_setup(self) -> None: + """Perform async setup for the coordinator.""" + # Start the server + try: + await self._server.start() + except OSError as ex: + raise UpdateFailed from ex + + async def _async_update_data(self) -> None: + """Empty update method since data is pushed.""" + + async def disconnect(self) -> None: + """Disconnect from the server.""" + self._server.set_on_update_callback(None) + self._server.set_on_connect_callback(None) + self._server.set_on_disconnect_callback(None) + self._server.set_new_client_callback(None) + self._server.stop() + + @property + def server(self) -> Snapserver: + """Get the Snapserver object.""" + return self._server diff --git a/homeassistant/components/snapcast/entity.py b/homeassistant/components/snapcast/entity.py new file mode 100644 index 00000000000..cceeb6227fd --- /dev/null +++ b/homeassistant/components/snapcast/entity.py @@ -0,0 +1,11 @@ +"""Coordinator entity for Snapcast server.""" + +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import SnapcastUpdateCoordinator + + +class SnapcastCoordinatorEntity(CoordinatorEntity[SnapcastUpdateCoordinator]): + """Coordinator entity for Snapcast.""" diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index bda411acde3..0ec27c1ad9c 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,18 +2,29 @@ from __future__ import annotations -from snapcast.control.server import Snapserver +from collections.abc import Mapping +import logging +from typing import Any + +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup import voluptuous as vol from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -30,6 +41,8 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .coordinator import SnapcastUpdateCoordinator +from .entity import SnapcastCoordinatorEntity STREAM_STATUS = { "idle": MediaPlayerState.IDLE, @@ -37,21 +50,23 @@ STREAM_STATUS = { "unknown": None, } +_LOGGER = logging.getLogger(__name__) -def register_services(): + +def register_services() -> None: """Register snapcast services.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore") platform.async_register_entity_service( - SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, handle_async_join + SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join" ) - platform.async_register_entity_service(SERVICE_UNJOIN, None, handle_async_unjoin) + platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin") platform.async_register_entity_service( SERVICE_SET_LATENCY, {vol.Required(ATTR_LATENCY): cv.positive_int}, - handle_set_latency, + "async_set_latency", ) @@ -61,51 +76,103 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" - snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server + + # Fetch coordinator from global data + coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Create an ID for the Snapserver + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + host_id = f"{host}:{port}" register_services() - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - hpid = f"{host}:{port}" + _known_group_ids: set[str] = set() + _known_client_ids: set[str] = set() - groups: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid, config_entry.entry_id) - for group in snapcast_server.groups - ] - clients: list[MediaPlayerEntity] = [ - SnapcastClientDevice(client, hpid, config_entry.entry_id) - for client in snapcast_server.clients - ] - async_add_entities(clients + groups) - hass.data[DOMAIN][ - config_entry.entry_id - ].hass_async_add_entities = async_add_entities + @callback + def _check_entities() -> None: + nonlocal _known_group_ids, _known_client_ids + + def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]: + ids_to_add = ids - known_ids + ids_to_remove = known_ids - ids + + # Update known IDs + known_ids.difference_update(ids_to_remove) + known_ids.update(ids_to_add) + + return ids_to_add, ids_to_remove + + group_ids = {g.identifier for g in coordinator.server.groups} + groups_to_add, groups_to_remove = _update_known_ids(_known_group_ids, group_ids) + + client_ids = {c.identifier for c in coordinator.server.clients} + clients_to_add, clients_to_remove = _update_known_ids( + _known_client_ids, client_ids + ) + + # Exit early if no changes + if not (groups_to_add | groups_to_remove | clients_to_add | clients_to_remove): + return + + _LOGGER.debug( + "New clients: %s", + str([coordinator.server.client(c).friendly_name for c in clients_to_add]), + ) + _LOGGER.debug( + "New groups: %s", + str([coordinator.server.group(g).friendly_name for g in groups_to_add]), + ) + _LOGGER.debug( + "Remove client IDs: %s", + str([list(clients_to_remove)]), + ) + _LOGGER.debug( + "Remove group IDs: %s", + str(list(groups_to_remove)), + ) + + # Add new entities + async_add_entities( + [ + SnapcastGroupDevice( + coordinator, coordinator.server.group(group_id), host_id + ) + for group_id in groups_to_add + ] + + [ + SnapcastClientDevice( + coordinator, coordinator.server.client(client_id), host_id + ) + for client_id in clients_to_add + ] + ) + + # Remove stale entities + entity_registry = er.async_get(hass) + for group_id in groups_to_remove: + if entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + SnapcastGroupDevice.get_unique_id(host_id, group_id), + ): + entity_registry.async_remove(entity_id) + + for client_id in clients_to_remove: + if entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + SnapcastClientDevice.get_unique_id(host_id, client_id), + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(_check_entities) + _check_entities() -async def handle_async_join(entity, service_call): - """Handle the entity service join.""" - if not isinstance(entity, SnapcastClientDevice): - raise TypeError("Entity is not a client. Can only join clients.") - await entity.async_join(service_call.data[ATTR_MASTER]) - - -async def handle_async_unjoin(entity, service_call): - """Handle the entity service unjoin.""" - if not isinstance(entity, SnapcastClientDevice): - raise TypeError("Entity is not a client. Can only unjoin clients.") - await entity.async_unjoin() - - -async def handle_set_latency(entity, service_call): - """Handle the entity service set_latency.""" - if not isinstance(entity, SnapcastClientDevice): - raise TypeError("Latency can only be set for a Snapcast client.") - await entity.async_set_latency(service_call.data[ATTR_LATENCY]) - - -class SnapcastGroupDevice(MediaPlayerEntity): - """Representation of a Snapcast group device.""" +class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): + """Base class representing a Snapcast device.""" _attr_should_poll = False _attr_supported_features = ( @@ -114,166 +181,172 @@ class SnapcastGroupDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, group, uid_part, entry_id): - """Initialize the Snapcast group device.""" - self._attr_available = True - self._group = group - self._entry_id = entry_id - self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" + def __init__( + self, + coordinator: SnapcastUpdateCoordinator, + device: Snapgroup | Snapclient, + host_id: str, + ) -> None: + """Initialize the base device.""" + super().__init__(coordinator) + + self._device = device + self._attr_unique_id = self.get_unique_id(host_id, device.identifier) + + @classmethod + def get_unique_id(cls, host, id) -> str: + """Build a unique ID.""" + raise NotImplementedError + + @property + def _current_group(self) -> Snapgroup: + """Return the group.""" + raise NotImplementedError async def async_added_to_hass(self) -> None: - """Subscribe to group events.""" - self._group.set_callback(self.schedule_update_ha_state) - self.hass.data[DOMAIN][self._entry_id].groups.append(self) + """Subscribe to events.""" + await super().async_added_to_hass() + self._device.set_callback(self.schedule_update_ha_state) async def async_will_remove_from_hass(self) -> None: - """Disconnect group object when removed.""" - self._group.set_callback(None) - self.hass.data[DOMAIN][self._entry_id].groups.remove(self) + """Disconnect object when removed.""" + self._device.set_callback(None) - def set_availability(self, available: bool) -> None: - """Set availability of group.""" - self._attr_available = available - self.schedule_update_ha_state() + @property + def identifier(self) -> str: + """Return the snapcast identifier.""" + return self._device.identifier + + @property + def source(self) -> str | None: + """Return the current input source.""" + return self._current_group.stream + + @property + def source_list(self) -> list[str]: + """List of available input sources.""" + return list(self._current_group.streams_by_name().keys()) + + async def async_select_source(self, source: str) -> None: + """Set input source.""" + streams = self._current_group.streams_by_name() + if source in streams: + await self._current_group.set_stream(streams[source].identifier) + self.async_write_ha_state() + + @property + def is_volume_muted(self) -> bool: + """Volume muted.""" + return self._device.muted + + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + await self._device.set_muted(mute) + self.async_write_ha_state() + + @property + def volume_level(self) -> float: + """Return the volume level.""" + return self._device.volume / 100 + + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + await self._device.set_volume(round(volume * 100)) + self.async_write_ha_state() + + def snapshot(self) -> None: + """Snapshot the group state.""" + self._device.snapshot() + + async def async_restore(self) -> None: + """Restore the group state.""" + await self._device.restore() + self.async_write_ha_state() + + async def async_set_latency(self, latency) -> None: + """Handle the set_latency service.""" + raise NotImplementedError + + async def async_join(self, master) -> None: + """Handle the join service.""" + raise NotImplementedError + + async def async_unjoin(self) -> None: + """Handle the unjoin service.""" + raise NotImplementedError + + +class SnapcastGroupDevice(SnapcastBaseDevice): + """Representation of a Snapcast group device.""" + + _device: Snapgroup + + @classmethod + def get_unique_id(cls, host, id) -> str: + """Get a unique ID for a group.""" + return f"{GROUP_PREFIX}{host}_{id}" + + @property + def _current_group(self) -> Snapgroup: + """Return the group.""" + return self._device + + @property + def name(self) -> str: + """Return the name of the device.""" + return f"{self._device.friendly_name} {GROUP_SUFFIX}" @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self.is_volume_muted: return MediaPlayerState.IDLE - return STREAM_STATUS.get(self._group.stream_status) + return STREAM_STATUS.get(self._device.stream_status) - @property - def identifier(self): - """Return the snapcast identifier.""" - return self._group.identifier + async def async_set_latency(self, latency) -> None: + """Handle the set_latency service.""" + raise ServiceValidationError("Latency can only be set for a Snapcast client.") - @property - def name(self): - """Return the name of the device.""" - return f"{self._group.friendly_name} {GROUP_SUFFIX}" + async def async_join(self, master) -> None: + """Handle the join service.""" + raise ServiceValidationError("Entity is not a client. Can only join clients.") - @property - def source(self): - """Return the current input source.""" - return self._group.stream - - @property - def volume_level(self): - """Return the volume level.""" - return self._group.volume / 100 - - @property - def is_volume_muted(self): - """Volume muted.""" - return self._group.muted - - @property - def source_list(self): - """List of available input sources.""" - return list(self._group.streams_by_name().keys()) - - async def async_select_source(self, source: str) -> None: - """Set input source.""" - streams = self._group.streams_by_name() - if source in streams: - await self._group.set_stream(streams[source].identifier) - self.async_write_ha_state() - - async def async_mute_volume(self, mute: bool) -> None: - """Send the mute command.""" - await self._group.set_muted(mute) - self.async_write_ha_state() - - async def async_set_volume_level(self, volume: float) -> None: - """Set the volume level.""" - await self._group.set_volume(round(volume * 100)) - self.async_write_ha_state() - - def snapshot(self): - """Snapshot the group state.""" - self._group.snapshot() - - async def async_restore(self): - """Restore the group state.""" - await self._group.restore() - self.async_write_ha_state() + async def async_unjoin(self) -> None: + """Handle the unjoin service.""" + raise ServiceValidationError("Entity is not a client. Can only unjoin clients.") -class SnapcastClientDevice(MediaPlayerEntity): +class SnapcastClientDevice(SnapcastBaseDevice): """Representation of a Snapcast client device.""" - _attr_should_poll = False - _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + _device: Snapclient - def __init__(self, client, uid_part, entry_id): - """Initialize the Snapcast client device.""" - self._attr_available = True - self._client = client - # Note: Host part is needed, when using multiple snapservers - self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" - self._entry_id = entry_id - - async def async_added_to_hass(self) -> None: - """Subscribe to client events.""" - self._client.set_callback(self.schedule_update_ha_state) - self.hass.data[DOMAIN][self._entry_id].clients.append(self) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect client object when removed.""" - self._client.set_callback(None) - self.hass.data[DOMAIN][self._entry_id].clients.remove(self) - - def set_availability(self, available: bool) -> None: - """Set availability of group.""" - self._attr_available = available - self.schedule_update_ha_state() + @classmethod + def get_unique_id(cls, host, id) -> str: + """Get a unique ID for a client.""" + return f"{CLIENT_PREFIX}{host}_{id}" @property - def identifier(self): - """Return the snapcast identifier.""" - return self._client.identifier + def _current_group(self) -> Snapgroup: + """Return the group the client is associated with.""" + return self._device.group @property - def name(self): + def name(self) -> str: """Return the name of the device.""" - return f"{self._client.friendly_name} {CLIENT_SUFFIX}" - - @property - def source(self): - """Return the current input source.""" - return self._client.group.stream - - @property - def volume_level(self): - """Return the volume level.""" - return self._client.volume / 100 - - @property - def is_volume_muted(self): - """Volume muted.""" - return self._client.muted - - @property - def source_list(self): - """List of available input sources.""" - return list(self._client.group.streams_by_name().keys()) + return f"{self._device.friendly_name} {CLIENT_SUFFIX}" @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - if self._client.connected: - if self.is_volume_muted or self._client.group.muted: + if self._device.connected: + if self.is_volume_muted or self._current_group.muted: return MediaPlayerState.IDLE - return STREAM_STATUS.get(self._client.group.stream_status) + return STREAM_STATUS.get(self._current_group.stream_status) return MediaPlayerState.STANDBY @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" state_attrs = {} if self.latency is not None: @@ -281,60 +354,40 @@ class SnapcastClientDevice(MediaPlayerEntity): return state_attrs @property - def latency(self): + def latency(self) -> float | None: """Latency for Client.""" - return self._client.latency + return self._device.latency - async def async_select_source(self, source: str) -> None: - """Set input source.""" - streams = self._client.group.streams_by_name() - if source in streams: - await self._client.group.set_stream(streams[source].identifier) - self.async_write_ha_state() - - async def async_mute_volume(self, mute: bool) -> None: - """Send the mute command.""" - await self._client.set_muted(mute) + async def async_set_latency(self, latency) -> None: + """Set the latency of the client.""" + await self._device.set_latency(latency) self.async_write_ha_state() - async def async_set_volume_level(self, volume: float) -> None: - """Set the volume level.""" - await self._client.set_volume(round(volume * 100)) - self.async_write_ha_state() - - async def async_join(self, master): + async def async_join(self, master) -> None: """Join the group of the master player.""" - master_entity = next( - entity - for entity in self.hass.data[DOMAIN][self._entry_id].clients - if entity.entity_id == master - ) - if not isinstance(master_entity, SnapcastClientDevice): - raise TypeError("Master is not a client device. Can only join clients.") + entity_registry = er.async_get(self.hass) + master_entity = entity_registry.async_get(master) + if master_entity is None: + raise ServiceValidationError(f"Master entity '{master}' not found.") + # Validate master entity is a client + unique_id = master_entity.unique_id + if not unique_id.startswith(CLIENT_PREFIX): + raise ServiceValidationError( + "Master is not a client device. Can only join clients." + ) + + # Extract the client ID and locate it's group + identifier = unique_id.split("_")[-1] master_group = next( group - for group in self._client.groups_available() - if master_entity.identifier in group.clients + for group in self._device.groups_available() + if identifier in group.clients ) - await master_group.add_client(self._client.identifier) + await master_group.add_client(self._device.identifier) self.async_write_ha_state() - async def async_unjoin(self): + async def async_unjoin(self) -> None: """Unjoin the group the player is currently in.""" - await self._client.group.remove_client(self._client.identifier) - self.async_write_ha_state() - - def snapshot(self): - """Snapshot the client state.""" - self._client.snapshot() - - async def async_restore(self): - """Restore the client state.""" - await self._client.restore() - self.async_write_ha_state() - - async def async_set_latency(self, latency): - """Set the latency of the client.""" - await self._client.set_latency(latency) + await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py deleted file mode 100644 index ab4091e30af..00000000000 --- a/homeassistant/components/snapcast/server.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Snapcast Integration.""" - -from __future__ import annotations - -import logging - -import snapcast.control -from snapcast.control.client import Snapclient - -from homeassistant.components.media_player import MediaPlayerEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .media_player import SnapcastClientDevice, SnapcastGroupDevice - -_LOGGER = logging.getLogger(__name__) - - -class HomeAssistantSnapcast: - """Snapcast server and data stored in the Home Assistant data object.""" - - hass: HomeAssistant - - def __init__( - self, - hass: HomeAssistant, - server: snapcast.control.Snapserver, - hpid: str, - entry_id: str, - ) -> None: - """Initialize the HomeAssistantSnapcast object. - - Parameters - ---------- - hass: HomeAssistant - hass object - server : snapcast.control.Snapserver - Snapcast server - hpid : str - host and port - entry_id: str - ConfigEntry entry_id - - Returns - ------- - None - - """ - self.hass: HomeAssistant = hass - self.server: snapcast.control.Snapserver = server - self.hpid: str = hpid - self._entry_id = entry_id - self.clients: list[SnapcastClientDevice] = [] - self.groups: list[SnapcastGroupDevice] = [] - self.hass_async_add_entities: AddEntitiesCallback - # connect callbacks - self.server.set_on_update_callback(self.on_update) - self.server.set_on_connect_callback(self.on_connect) - self.server.set_on_disconnect_callback(self.on_disconnect) - self.server.set_new_client_callback(self.on_add_client) - - async def disconnect(self) -> None: - """Disconnect from server.""" - self.server.set_on_update_callback(None) - self.server.set_on_connect_callback(None) - self.server.set_on_disconnect_callback(None) - self.server.set_new_client_callback(None) - self.server.stop() - - def on_update(self) -> None: - """Update all entities. - - Retrieve all groups/clients from server and add/update/delete entities. - """ - if not self.hass_async_add_entities: - return - new_groups: list[MediaPlayerEntity] = [] - groups: list[MediaPlayerEntity] = [] - hass_groups = {g.identifier: g for g in self.groups} - for group in self.server.groups: - if group.identifier in hass_groups: - groups.append(hass_groups[group.identifier]) - hass_groups[group.identifier].async_schedule_update_ha_state() - else: - new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id)) - new_clients: list[MediaPlayerEntity] = [] - clients: list[MediaPlayerEntity] = [] - hass_clients = {c.identifier: c for c in self.clients} - for client in self.server.clients: - if client.identifier in hass_clients: - clients.append(hass_clients[client.identifier]) - hass_clients[client.identifier].async_schedule_update_ha_state() - else: - new_clients.append( - SnapcastClientDevice(client, self.hpid, self._entry_id) - ) - del_entities: list[MediaPlayerEntity] = [ - x for x in self.groups if x not in groups - ] - del_entities.extend([x for x in self.clients if x not in clients]) - - _LOGGER.debug("New clients: %s", str([c.name for c in new_clients])) - _LOGGER.debug("New groups: %s", str([g.name for g in new_groups])) - _LOGGER.debug("Delete: %s", str(del_entities)) - - ent_reg = er.async_get(self.hass) - for entity in del_entities: - ent_reg.async_remove(entity.entity_id) - self.hass_async_add_entities(new_clients + new_groups) - - def on_connect(self) -> None: - """Activate all entities and update.""" - for client in self.clients: - client.set_availability(True) - for group in self.groups: - group.set_availability(True) - _LOGGER.debug("Server connected: %s", self.hpid) - self.on_update() - - def on_disconnect(self, ex: Exception | None) -> None: - """Deactivate all entities.""" - for client in self.clients: - client.set_availability(False) - for group in self.groups: - group.set_availability(False) - _LOGGER.warning( - "Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "") - ) - - def on_add_client(self, client: Snapclient) -> None: - """Add a Snapcast client. - - Parameters - ---------- - client : Snapclient - Snapcast client to be added to HA. - - """ - if not self.hass_async_add_entities: - return - clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)] - self.hass_async_add_entities(clients) diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index 8c721432709..bfe773b4780 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -83,7 +83,6 @@ class SnoozFan(FanEntity, RestoreEntity): _attr_should_poll = False _is_on: bool | None = None _percentage: int | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index bbd9b509ecf..bf87b0b0938 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -26,6 +26,10 @@ "data": { "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "has_password": "[%key:component::solarlog::config::step::user::data_description::has_password%]", + "password": "[%key:component::solarlog::config::step::password::data_description::password%]" } }, "reconfigure": { @@ -33,6 +37,10 @@ "data": { "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "has_password": "[%key:component::solarlog::config::step::user::data_description::has_password%]", + "password": "[%key:component::solarlog::config::step::password::data_description::password%]" } } }, diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 93d448bd17f..90281fe311c 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -1,6 +1,7 @@ """Support for the SpaceAPI.""" from contextlib import suppress +import math import voluptuous as vol @@ -254,7 +255,17 @@ class APISpaceApiView(HomeAssistantView): """Get data from a sensor.""" if not (sensor_state := hass.states.get(sensor)): return None - sensor_data = {ATTR_NAME: sensor_state.name, ATTR_VALUE: sensor_state.state} + + # SpaceAPI sensor values must be numbers + try: + state = float(sensor_state.state) + except ValueError: + state = math.nan + sensor_data = { + ATTR_NAME: sensor_state.name, + ATTR_VALUE: state, + } + if ATTR_SENSOR_LOCATION in sensor_state.attributes: sensor_data[ATTR_LOCATION] = sensor_state.attributes[ATTR_SENSOR_LOCATION] else: diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index a891941fb8e..89d03a4fadc 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -14,8 +14,11 @@ from starlink_grpc import ( GrpcError, LocationDict, ObstructionDict, + PowerDict, StatusDict, + UsageDict, get_sleep_config, + history_stats, location_data, reboot, set_sleep_config, @@ -39,6 +42,8 @@ class StarlinkData: status: StatusDict obstruction: ObstructionDict alert: AlertDict + usage: UsageDict + consumption: PowerDict class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): @@ -57,11 +62,15 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): def _get_starlink_data(self) -> StarlinkData: """Retrieve Starlink data.""" - channel_context = self.channel_context - status = status_data(channel_context) - location = location_data(channel_context) - sleep = get_sleep_config(channel_context) - return StarlinkData(location, sleep, *status) + context = self.channel_context + status = status_data(context) + location = location_data(context) + sleep = get_sleep_config(context) + status, obstruction, alert = status_data(context) + usage, consumption = history_stats(parse_samples=-1, context=context)[-2:] + return StarlinkData( + location, sleep, status, obstruction, alert, usage, consumption + ) async def _async_update_data(self) -> StarlinkData: async with asyncio.timeout(4): diff --git a/homeassistant/components/starlink/icons.json b/homeassistant/components/starlink/icons.json index 65cb273e24b..02de62aeb8a 100644 --- a/homeassistant/components/starlink/icons.json +++ b/homeassistant/components/starlink/icons.json @@ -18,6 +18,12 @@ }, "last_boot_time": { "default": "mdi:clock" + }, + "upload": { + "default": "mdi:upload" + }, + "download": { + "default": "mdi:download" } } } diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index 070cbf1b44c..15bad3ebc2e 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", - "requirements": ["starlink-grpc-core==1.2.0"] + "requirements": ["starlink-grpc-core==1.2.2"] } diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 21f2400022c..5481e310fbd 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -18,6 +18,9 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, UnitOfDataRate, + UnitOfEnergy, + UnitOfInformation, + UnitOfPower, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -120,4 +123,36 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: data.status["pop_ping_drop_rate"] * 100, ), + StarlinkSensorEntityDescription( + key="upload", + translation_key="upload", + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + value_fn=lambda data: data.usage["upload_usage"], + ), + StarlinkSensorEntityDescription( + key="download", + translation_key="download", + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + value_fn=lambda data: data.usage["download_usage"], + ), + StarlinkSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda data: data.consumption["latest_power"], + ), + StarlinkSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda data: data.consumption["total_energy"], + ), ) diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index 36a4f176e70..395b6288c71 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -70,6 +70,12 @@ }, "ping_drop_rate": { "name": "Ping drop rate" + }, + "upload": { + "name": "Upload" + }, + "download": { + "name": "Download" } }, "switch": { diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 4280c92131a..4c78afbde9c 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -57,9 +57,9 @@ async def get_state_characteristics(handler: SchemaCommonFlowHandler) -> vol.Sch split_entity_id(handler.options[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN ) if is_binary: - options = STATS_BINARY_SUPPORT + options = list(STATS_BINARY_SUPPORT) else: - options = STATS_NUMERIC_SUPPORT + options = list(STATS_NUMERIC_SUPPORT) return vol.Schema( { diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 24d4b4914cb..8eaed552edd 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -2,7 +2,7 @@ "domain": "statistics", "name": "Statistics", "after_dependencies": ["recorder"], - "codeowners": ["@ThomDietrich"], + "codeowners": ["@ThomDietrich", "@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/statistics", "integration_type": "helper", diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index b6f1844f774..5252c23fd3d 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import logging import math import statistics +import time from typing import Any, cast import voluptuous as vol @@ -53,7 +54,7 @@ from homeassistant.helpers.event import ( async_track_state_report_event, ) from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -97,47 +98,373 @@ STAT_VALUE_MAX = "value_max" STAT_VALUE_MIN = "value_min" STAT_VARIANCE = "variance" + +def _callable_characteristic_fn( + characteristic: str, binary: bool +) -> Callable[[deque[bool | float], deque[float], int], float | int | datetime | None]: + """Return the function callable of one characteristic function.""" + Callable[[deque[bool | float], deque[datetime], int], datetime | int | float | None] + if binary: + return STATS_BINARY_SUPPORT[characteristic] + return STATS_NUMERIC_SUPPORT[characteristic] + + +# Statistics for numeric sensor + + +def _stat_average_linear( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return states[0] + if len(states) >= 2: + area: float = 0 + for i in range(1, len(states)): + area += 0.5 * (states[i] + states[i - 1]) * (ages[i] - ages[i - 1]) + age_range_seconds = ages[-1] - ages[0] + return area / age_range_seconds + return None + + +def _stat_average_step( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return states[0] + if len(states) >= 2: + area: float = 0 + for i in range(1, len(states)): + area += states[i - 1] * (ages[i] - ages[i - 1]) + age_range_seconds = ages[-1] - ages[0] + return area / age_range_seconds + return None + + +def _stat_average_timeless( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + return _stat_mean(states, ages, percentile) + + +def _stat_change( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return states[-1] - states[0] + return None + + +def _stat_change_sample( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 1: + return (states[-1] - states[0]) / (len(states) - 1) + return None + + +def _stat_change_second( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 1: + age_range_seconds = ages[-1] - ages[0] + if age_range_seconds > 0: + return (states[-1] - states[0]) / age_range_seconds + return None + + +def _stat_count( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return len(states) + + +def _stat_datetime_newest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[-1]) + return None + + +def _stat_datetime_oldest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[0]) + return None + + +def _stat_datetime_value_max( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[states.index(max(states))]) + return None + + +def _stat_datetime_value_min( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[states.index(min(states))]) + return None + + +def _stat_distance_95_percent_of_values( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) >= 1: + return ( + 2 * 1.96 * cast(float, _stat_standard_deviation(states, ages, percentile)) + ) + return None + + +def _stat_distance_99_percent_of_values( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) >= 1: + return ( + 2 * 2.58 * cast(float, _stat_standard_deviation(states, ages, percentile)) + ) + return None + + +def _stat_distance_absolute( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return max(states) - min(states) + return None + + +def _stat_mean( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return statistics.mean(states) + return None + + +def _stat_mean_circular( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + sin_sum = sum(math.sin(math.radians(x)) for x in states) + cos_sum = sum(math.cos(math.radians(x)) for x in states) + return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360 + return None + + +def _stat_median( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return statistics.median(states) + return None + + +def _stat_noisiness( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return cast(float, _stat_sum_differences(states, ages, percentile)) / ( + len(states) - 1 + ) + return None + + +def _stat_percentile( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return states[0] + if len(states) >= 2: + percentiles = statistics.quantiles(states, n=100, method="exclusive") + return percentiles[percentile - 1] + return None + + +def _stat_standard_deviation( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return statistics.stdev(states) + return None + + +def _stat_sum( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return sum(states) + return None + + +def _stat_sum_differences( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return sum( + abs(j - i) for i, j in zip(list(states), list(states)[1:], strict=False) + ) + return None + + +def _stat_sum_differences_nonnegative( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return sum( + (j - i if j >= i else j - 0) + for i, j in zip(list(states), list(states)[1:], strict=False) + ) + return None + + +def _stat_total( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + return _stat_sum(states, ages, percentile) + + +def _stat_value_max( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return max(states) + return None + + +def _stat_value_min( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return min(states) + return None + + +def _stat_variance( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return statistics.variance(states) + return None + + +# Statistics for binary sensor + + +def _stat_binary_average_step( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 100.0 * int(states[0] is True) + if len(states) >= 2: + on_seconds: float = 0 + for i in range(1, len(states)): + if states[i - 1] is True: + on_seconds += ages[i] - ages[i - 1] + age_range_seconds = ages[-1] - ages[0] + return 100 / age_range_seconds * on_seconds + return None + + +def _stat_binary_average_timeless( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + return _stat_binary_mean(states, ages, percentile) + + +def _stat_binary_count( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return len(states) + + +def _stat_binary_count_on( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return states.count(True) + + +def _stat_binary_count_off( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return states.count(False) + + +def _stat_binary_datetime_newest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + return _stat_datetime_newest(states, ages, percentile) + + +def _stat_binary_datetime_oldest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + return _stat_datetime_oldest(states, ages, percentile) + + +def _stat_binary_mean( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return 100.0 / len(states) * states.count(True) + return None + + # Statistics supported by a sensor source (numeric) STATS_NUMERIC_SUPPORT = { - STAT_AVERAGE_LINEAR, - STAT_AVERAGE_STEP, - STAT_AVERAGE_TIMELESS, - STAT_CHANGE_SAMPLE, - STAT_CHANGE_SECOND, - STAT_CHANGE, - STAT_COUNT, - STAT_DATETIME_NEWEST, - STAT_DATETIME_OLDEST, - STAT_DATETIME_VALUE_MAX, - STAT_DATETIME_VALUE_MIN, - STAT_DISTANCE_95P, - STAT_DISTANCE_99P, - STAT_DISTANCE_ABSOLUTE, - STAT_MEAN, - STAT_MEAN_CIRCULAR, - STAT_MEDIAN, - STAT_NOISINESS, - STAT_PERCENTILE, - STAT_STANDARD_DEVIATION, - STAT_SUM, - STAT_SUM_DIFFERENCES, - STAT_SUM_DIFFERENCES_NONNEGATIVE, - STAT_TOTAL, - STAT_VALUE_MAX, - STAT_VALUE_MIN, - STAT_VARIANCE, + STAT_AVERAGE_LINEAR: _stat_average_linear, + STAT_AVERAGE_STEP: _stat_average_step, + STAT_AVERAGE_TIMELESS: _stat_average_timeless, + STAT_CHANGE_SAMPLE: _stat_change_sample, + STAT_CHANGE_SECOND: _stat_change_second, + STAT_CHANGE: _stat_change, + STAT_COUNT: _stat_count, + STAT_DATETIME_NEWEST: _stat_datetime_newest, + STAT_DATETIME_OLDEST: _stat_datetime_oldest, + STAT_DATETIME_VALUE_MAX: _stat_datetime_value_max, + STAT_DATETIME_VALUE_MIN: _stat_datetime_value_min, + STAT_DISTANCE_95P: _stat_distance_95_percent_of_values, + STAT_DISTANCE_99P: _stat_distance_99_percent_of_values, + STAT_DISTANCE_ABSOLUTE: _stat_distance_absolute, + STAT_MEAN: _stat_mean, + STAT_MEAN_CIRCULAR: _stat_mean_circular, + STAT_MEDIAN: _stat_median, + STAT_NOISINESS: _stat_noisiness, + STAT_PERCENTILE: _stat_percentile, + STAT_STANDARD_DEVIATION: _stat_standard_deviation, + STAT_SUM: _stat_sum, + STAT_SUM_DIFFERENCES: _stat_sum_differences, + STAT_SUM_DIFFERENCES_NONNEGATIVE: _stat_sum_differences_nonnegative, + STAT_TOTAL: _stat_total, + STAT_VALUE_MAX: _stat_value_max, + STAT_VALUE_MIN: _stat_value_min, + STAT_VARIANCE: _stat_variance, } # Statistics supported by a binary_sensor source STATS_BINARY_SUPPORT = { - STAT_AVERAGE_STEP, - STAT_AVERAGE_TIMELESS, - STAT_COUNT, - STAT_COUNT_BINARY_ON, - STAT_COUNT_BINARY_OFF, - STAT_DATETIME_NEWEST, - STAT_DATETIME_OLDEST, - STAT_MEAN, + STAT_AVERAGE_STEP: _stat_binary_average_step, + STAT_AVERAGE_TIMELESS: _stat_binary_average_timeless, + STAT_COUNT: _stat_binary_count, + STAT_COUNT_BINARY_ON: _stat_binary_count_on, + STAT_COUNT_BINARY_OFF: _stat_binary_count_off, + STAT_DATETIME_NEWEST: _stat_binary_datetime_newest, + STAT_DATETIME_OLDEST: _stat_binary_datetime_oldest, + STAT_MEAN: _stat_binary_mean, } STATS_NOT_A_NUMBER = { @@ -298,12 +625,8 @@ async def async_setup_entry( sampling_size = int(sampling_size) max_age = None - if max_age_input := entry.options.get(CONF_MAX_AGE): - max_age = timedelta( - hours=max_age_input["hours"], - minutes=max_age_input["minutes"], - seconds=max_age_input["seconds"], - ) + if max_age := entry.options.get(CONF_MAX_AGE): + max_age = timedelta(**max_age) async_add_entities( [ @@ -356,19 +679,22 @@ class StatisticsSensor(SensorEntity): ) self._state_characteristic: str = state_characteristic self._samples_max_buffer_size: int | None = samples_max_buffer_size - self._samples_max_age: timedelta | None = samples_max_age + self._samples_max_age: float | None = ( + samples_max_age.total_seconds() if samples_max_age else None + ) self.samples_keep_last: bool = samples_keep_last self._precision: int = precision self._percentile: int = percentile self._attr_available: bool = False - self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size) - self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size) + self.states: deque[float | bool] = deque(maxlen=samples_max_buffer_size) + self.ages: deque[float] = deque(maxlen=samples_max_buffer_size) self._attr_extra_state_attributes = {} - self._state_characteristic_fn: Callable[[], float | int | datetime | None] = ( - self._callable_characteristic_fn(self._state_characteristic) - ) + self._state_characteristic_fn: Callable[ + [deque[bool | float], deque[float], int], + float | int | datetime | None, + ] = _callable_characteristic_fn(state_characteristic, self.is_binary) self._update_listener: CALLBACK_TYPE | None = None self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None @@ -474,7 +800,7 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state == "on") else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_reported) + self.ages.append(new_state.last_reported_timestamp) self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False @@ -507,27 +833,24 @@ class StatisticsSensor(SensorEntity): base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) unit: str | None = None - if self.is_binary and self._state_characteristic in STATS_BINARY_PERCENTAGE: + stat_type = self._state_characteristic + if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE: unit = PERCENTAGE elif not base_unit: unit = None - elif self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: + elif stat_type in STATS_NUMERIC_RETAIN_UNIT: unit = base_unit - elif ( - self._state_characteristic in STATS_NOT_A_NUMBER - or self._state_characteristic - in ( - STAT_COUNT, - STAT_COUNT_BINARY_ON, - STAT_COUNT_BINARY_OFF, - ) + elif stat_type in STATS_NOT_A_NUMBER or stat_type in ( + STAT_COUNT, + STAT_COUNT_BINARY_ON, + STAT_COUNT_BINARY_OFF, ): unit = None - elif self._state_characteristic == STAT_VARIANCE: + elif stat_type == STAT_VARIANCE: unit = base_unit + "²" - elif self._state_characteristic == STAT_CHANGE_SAMPLE: + elif stat_type == STAT_CHANGE_SAMPLE: unit = base_unit + "/sample" - elif self._state_characteristic == STAT_CHANGE_SECOND: + elif stat_type == STAT_CHANGE_SECOND: unit = base_unit + "/s" return unit @@ -543,9 +866,10 @@ class StatisticsSensor(SensorEntity): """ device_class: SensorDeviceClass | None = None - if self._state_characteristic in STATS_DATETIME: + stat_type = self._state_characteristic + if stat_type in STATS_DATETIME: return SensorDeviceClass.TIMESTAMP - if self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: + if stat_type in STATS_NUMERIC_RETAIN_UNIT: device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) if device_class is None: return None @@ -584,55 +908,60 @@ class StatisticsSensor(SensorEntity): return None return SensorStateClass.MEASUREMENT - def _purge_old_states(self, max_age: timedelta) -> None: + def _purge_old_states(self, max_age: float) -> None: """Remove states which are older than a given age.""" - now = dt_util.utcnow() + now_timestamp = time.time() + debug = _LOGGER.isEnabledFor(logging.DEBUG) - _LOGGER.debug( - "%s: purging records older then %s(%s)(keep_last_sample: %s)", - self.entity_id, - dt_util.as_local(now - max_age), - self._samples_max_age, - self.samples_keep_last, - ) + if debug: + _LOGGER.debug( + "%s: purging records older then %s(%s)(keep_last_sample: %s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(now_timestamp - max_age)), + self._samples_max_age, + self.samples_keep_last, + ) - while self.ages and (now - self.ages[0]) > max_age: + while self.ages and (now_timestamp - self.ages[0]) > max_age: if self.samples_keep_last and len(self.ages) == 1: # Under normal circumstance this will not be executed, as a purge will not # be scheduled for the last value if samples_keep_last is enabled. # If this happens to be called outside normal scheduling logic or a # source sensor update, this ensures the last value is preserved. - _LOGGER.debug( - "%s: preserving expired record with datetime %s(%s)", - self.entity_id, - dt_util.as_local(self.ages[0]), - (now - self.ages[0]), - ) + if debug: + _LOGGER.debug( + "%s: preserving expired record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])), + dt_util.utc_from_timestamp(now_timestamp - self.ages[0]), + ) break - _LOGGER.debug( - "%s: purging record with datetime %s(%s)", - self.entity_id, - dt_util.as_local(self.ages[0]), - (now - self.ages[0]), - ) + if debug: + _LOGGER.debug( + "%s: purging record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])), + dt_util.utc_from_timestamp(now_timestamp - self.ages[0]), + ) self.ages.popleft() self.states.popleft() @callback - def _async_next_to_purge_timestamp(self) -> datetime | None: + def _async_next_to_purge_timestamp(self) -> float | None: """Find the timestamp when the next purge would occur.""" if self.ages and self._samples_max_age: if self.samples_keep_last and len(self.ages) == 1: # Preserve the most recent entry if it is the only value. # Do not schedule another purge. When a new source # value is inserted it will restart purge cycle. - _LOGGER.debug( - "%s: skipping purge cycle for last record with datetime %s(%s)", - self.entity_id, - dt_util.as_local(self.ages[0]), - (dt_util.utcnow() - self.ages[0]), - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "%s: skipping purge cycle for last record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])), + (dt_util.utcnow() - dt_util.utc_from_timestamp(self.ages[0])), + ) return None # Take the oldest entry from the ages list and add the configured max_age. # If executed after purging old states, the result is the next timestamp @@ -657,10 +986,17 @@ class StatisticsSensor(SensorEntity): # By basing updates off the timestamps of sampled data we avoid updating # when none of the observed entities change. if timestamp := self._async_next_to_purge_timestamp(): - _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "%s: scheduling update at %s", + self.entity_id, + dt_util.utc_from_timestamp(timestamp), + ) self._async_cancel_update_listener() self._update_listener = async_track_point_in_utc_time( - self.hass, self._async_scheduled_update, timestamp + self.hass, + self._async_scheduled_update, + dt_util.utc_from_timestamp(timestamp), ) @callback @@ -684,9 +1020,11 @@ class StatisticsSensor(SensorEntity): """Fetch the states from the database.""" _LOGGER.debug("%s: initializing values from the database", self.entity_id) lower_entity_id = self._source_entity_id.lower() - if self._samples_max_age is not None: + if (max_age := self._samples_max_age) is not None: start_date = ( - dt_util.utcnow() - self._samples_max_age - timedelta(microseconds=1) + dt_util.utcnow() + - timedelta(seconds=max_age) + - timedelta(microseconds=1) ) _LOGGER.debug( "%s: retrieve records not older then %s", @@ -738,11 +1076,10 @@ class StatisticsSensor(SensorEntity): len(self.states) / self._samples_max_buffer_size, 2 ) - if self._samples_max_age is not None: + if (max_age := self._samples_max_age) is not None: if len(self.states) >= 1: self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = round( - (self.ages[-1] - self.ages[0]).total_seconds() - / self._samples_max_age.total_seconds(), + (self.ages[-1] - self.ages[0]) / max_age, 2, ) else: @@ -754,7 +1091,7 @@ class StatisticsSensor(SensorEntity): One of the _stat_*() functions is represented by self._state_characteristic_fn(). """ - value = self._state_characteristic_fn() + value = self._state_characteristic_fn(self.states, self.ages, self._percentile) _LOGGER.debug( "Updating value: states: %s, ages: %s => %s", self.states, self.ages, value ) @@ -764,225 +1101,3 @@ class StatisticsSensor(SensorEntity): if self._precision == 0: value = int(value) self._attr_native_value = value - - def _callable_characteristic_fn( - self, characteristic: str - ) -> Callable[[], float | int | datetime | None]: - """Return the function callable of one characteristic function.""" - function: Callable[[], float | int | datetime | None] = getattr( - self, - f"_stat_binary_{characteristic}" - if self.is_binary - else f"_stat_{characteristic}", - ) - return function - - # Statistics for numeric sensor - - def _stat_average_linear(self) -> StateType: - if len(self.states) == 1: - return self.states[0] - if len(self.states) >= 2: - area: float = 0 - for i in range(1, len(self.states)): - area += ( - 0.5 - * (self.states[i] + self.states[i - 1]) - * (self.ages[i] - self.ages[i - 1]).total_seconds() - ) - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - return area / age_range_seconds - return None - - def _stat_average_step(self) -> StateType: - if len(self.states) == 1: - return self.states[0] - if len(self.states) >= 2: - area: float = 0 - for i in range(1, len(self.states)): - area += ( - self.states[i - 1] - * (self.ages[i] - self.ages[i - 1]).total_seconds() - ) - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - return area / age_range_seconds - return None - - def _stat_average_timeless(self) -> StateType: - return self._stat_mean() - - def _stat_change(self) -> StateType: - if len(self.states) > 0: - return self.states[-1] - self.states[0] - return None - - def _stat_change_sample(self) -> StateType: - if len(self.states) > 1: - return (self.states[-1] - self.states[0]) / (len(self.states) - 1) - return None - - def _stat_change_second(self) -> StateType: - if len(self.states) > 1: - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - if age_range_seconds > 0: - return (self.states[-1] - self.states[0]) / age_range_seconds - return None - - def _stat_count(self) -> StateType: - return len(self.states) - - def _stat_datetime_newest(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[-1] - return None - - def _stat_datetime_oldest(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[0] - return None - - def _stat_datetime_value_max(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[self.states.index(max(self.states))] - return None - - def _stat_datetime_value_min(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[self.states.index(min(self.states))] - return None - - def _stat_distance_95_percent_of_values(self) -> StateType: - if len(self.states) >= 1: - return 2 * 1.96 * cast(float, self._stat_standard_deviation()) - return None - - def _stat_distance_99_percent_of_values(self) -> StateType: - if len(self.states) >= 1: - return 2 * 2.58 * cast(float, self._stat_standard_deviation()) - return None - - def _stat_distance_absolute(self) -> StateType: - if len(self.states) > 0: - return max(self.states) - min(self.states) - return None - - def _stat_mean(self) -> StateType: - if len(self.states) > 0: - return statistics.mean(self.states) - return None - - def _stat_mean_circular(self) -> StateType: - if len(self.states) > 0: - sin_sum = sum(math.sin(math.radians(x)) for x in self.states) - cos_sum = sum(math.cos(math.radians(x)) for x in self.states) - return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360 - return None - - def _stat_median(self) -> StateType: - if len(self.states) > 0: - return statistics.median(self.states) - return None - - def _stat_noisiness(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return cast(float, self._stat_sum_differences()) / (len(self.states) - 1) - return None - - def _stat_percentile(self) -> StateType: - if len(self.states) == 1: - return self.states[0] - if len(self.states) >= 2: - percentiles = statistics.quantiles(self.states, n=100, method="exclusive") - return percentiles[self._percentile - 1] - return None - - def _stat_standard_deviation(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return statistics.stdev(self.states) - return None - - def _stat_sum(self) -> StateType: - if len(self.states) > 0: - return sum(self.states) - return None - - def _stat_sum_differences(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return sum( - abs(j - i) - for i, j in zip(list(self.states), list(self.states)[1:], strict=False) - ) - return None - - def _stat_sum_differences_nonnegative(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return sum( - (j - i if j >= i else j - 0) - for i, j in zip(list(self.states), list(self.states)[1:], strict=False) - ) - return None - - def _stat_total(self) -> StateType: - return self._stat_sum() - - def _stat_value_max(self) -> StateType: - if len(self.states) > 0: - return max(self.states) - return None - - def _stat_value_min(self) -> StateType: - if len(self.states) > 0: - return min(self.states) - return None - - def _stat_variance(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return statistics.variance(self.states) - return None - - # Statistics for binary sensor - - def _stat_binary_average_step(self) -> StateType: - if len(self.states) == 1: - return 100.0 * int(self.states[0] is True) - if len(self.states) >= 2: - on_seconds: float = 0 - for i in range(1, len(self.states)): - if self.states[i - 1] is True: - on_seconds += (self.ages[i] - self.ages[i - 1]).total_seconds() - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - return 100 / age_range_seconds * on_seconds - return None - - def _stat_binary_average_timeless(self) -> StateType: - return self._stat_binary_mean() - - def _stat_binary_count(self) -> StateType: - return len(self.states) - - def _stat_binary_count_on(self) -> StateType: - return self.states.count(True) - - def _stat_binary_count_off(self) -> StateType: - return self.states.count(False) - - def _stat_binary_datetime_newest(self) -> datetime | None: - return self._stat_datetime_newest() - - def _stat_binary_datetime_oldest(self) -> datetime | None: - return self._stat_datetime_oldest() - - def _stat_binary_mean(self) -> StateType: - if len(self.states) > 0: - return 100.0 / len(self.states) * self.states.count(True) - return None diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 41015ac16a4..676f613f382 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -80,7 +80,6 @@ class StiebelEltron(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, ste_data): """Initialize the unit.""" diff --git a/homeassistant/components/stookalert/__init__.py b/homeassistant/components/stookalert/__init__.py deleted file mode 100644 index 0ef9c7fa845..00000000000 --- a/homeassistant/components/stookalert/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""The Stookalert integration.""" - -from __future__ import annotations - -import stookalert - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from .const import CONF_PROVINCE, DOMAIN - -PLATFORMS = [Platform.BINARY_SENSOR] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Stookalert from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = stookalert.stookalert(entry.data[CONF_PROVINCE]) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload Stookalert config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py deleted file mode 100644 index a2fff52f2a3..00000000000 --- a/homeassistant/components/stookalert/binary_sensor.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Support for Stookalert Binary Sensor.""" - -from __future__ import annotations - -from datetime import timedelta - -import stookalert - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import CONF_PROVINCE, DOMAIN - -SCAN_INTERVAL = timedelta(minutes=60) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Stookalert binary sensor from a config entry.""" - client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([StookalertBinarySensor(client, entry)], update_before_add=True) - - -class StookalertBinarySensor(BinarySensorEntity): - """Defines a Stookalert binary sensor.""" - - _attr_attribution = "Data provided by rivm.nl" - _attr_device_class = BinarySensorDeviceClass.SAFETY - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, client: stookalert.stookalert, entry: ConfigEntry) -> None: - """Initialize a Stookalert device.""" - self._client = client - self._attr_unique_id = entry.unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.entry_id}")}, - name=f"Stookalert {entry.data[CONF_PROVINCE]}", - manufacturer="RIVM", - model="Stookalert", - entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.rivm.nl/stookalert", - ) - - def update(self) -> None: - """Update the data from the Stookalert handler.""" - self._client.get_alerts() - self._attr_is_on = self._client.state == 1 diff --git a/homeassistant/components/stookalert/config_flow.py b/homeassistant/components/stookalert/config_flow.py deleted file mode 100644 index 0d3bc0c1761..00000000000 --- a/homeassistant/components/stookalert/config_flow.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Config flow to configure the Stookalert integration.""" - -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult - -from .const import CONF_PROVINCE, DOMAIN, PROVINCES - - -class StookalertFlowHandler(ConfigFlow, domain=DOMAIN): - """Config flow for Stookalert.""" - - VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_PROVINCE]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_PROVINCE], data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required(CONF_PROVINCE): vol.In(PROVINCES)}), - ) diff --git a/homeassistant/components/stookalert/const.py b/homeassistant/components/stookalert/const.py deleted file mode 100644 index 9896eea212a..00000000000 --- a/homeassistant/components/stookalert/const.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Constants for the Stookalert integration.""" - -import logging -from typing import Final - -DOMAIN: Final = "stookalert" -LOGGER = logging.getLogger(__package__) - -CONF_PROVINCE: Final = "province" - -PROVINCES: Final = ( - "Drenthe", - "Flevoland", - "Friesland", - "Gelderland", - "Groningen", - "Limburg", - "Noord-Brabant", - "Noord-Holland", - "Overijssel", - "Utrecht", - "Zeeland", - "Zuid-Holland", -) diff --git a/homeassistant/components/stookalert/diagnostics.py b/homeassistant/components/stookalert/diagnostics.py deleted file mode 100644 index c15e808ae19..00000000000 --- a/homeassistant/components/stookalert/diagnostics.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Diagnostics support for Stookalert.""" - -from __future__ import annotations - -from typing import Any - -import stookalert - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .const import DOMAIN - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - client: stookalert.stookalert = hass.data[DOMAIN][entry.entry_id] - return {"state": client.state} diff --git a/homeassistant/components/stookalert/manifest.json b/homeassistant/components/stookalert/manifest.json deleted file mode 100644 index 2bebc639720..00000000000 --- a/homeassistant/components/stookalert/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "stookalert", - "name": "RIVM Stookalert", - "codeowners": ["@fwestenberg", "@frenck"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/stookalert", - "integration_type": "service", - "iot_class": "cloud_polling", - "requirements": ["stookalert==0.1.4"] -} diff --git a/homeassistant/components/stookalert/strings.json b/homeassistant/components/stookalert/strings.json deleted file mode 100644 index a05ae4e61e7..00000000000 --- a/homeassistant/components/stookalert/strings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "province": "Province" - } - } - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - } -} diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index fdf81d99e65..b9368565e2f 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.3"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.0"] } diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 06f503b85c2..cbaac912642 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -2,32 +2,27 @@ 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 SuezWaterCoordinator +from .coordinator import SuezWaterConfigEntry, SuezWaterCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool: """Set up Suez Water from a config entry.""" coordinator = SuezWaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index ac09cf4a1d3..b24dc1815ee 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -37,16 +37,19 @@ async def validate_input(data: dict[str, Any]) -> None: data[CONF_PASSWORD], counter_id, ) - if not await client.check_credentials(): - raise InvalidAuth - except PySuezError as ex: - raise CannotConnect from ex - - if counter_id is None: try: - data[CONF_COUNTER_ID] = await client.find_counter() + if not await client.check_credentials(): + raise InvalidAuth except PySuezError as ex: - raise CounterNotFound from ex + raise CannotConnect from ex + + if counter_id is None: + try: + data[CONF_COUNTER_ID] = await client.find_counter() + except PySuezError as ex: + raise CounterNotFound from ex + finally: + await client.close_session() class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): @@ -79,7 +82,10 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"tout_sur_mon_eau": "Tout sur mon Eau"}, ) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 224929c606e..72da68c0f5d 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -37,13 +37,16 @@ class SuezWaterData: price: float +type SuezWaterConfigEntry = ConfigEntry[SuezWaterCoordinator] + + class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): """Suez water coordinator.""" _suez_client: SuezClient - config_entry: ConfigEntry + config_entry: SuezWaterConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SuezWaterConfigEntry) -> None: """Initialize suez water coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 240be0f37bd..f39411e8afa 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuezV2==1.3.2"] + "quality_scale": "bronze", + "requirements": ["pysuezV2==1.3.5"] } diff --git a/homeassistant/components/suez_water/quality_scale.yaml b/homeassistant/components/suez_water/quality_scale.yaml new file mode 100644 index 00000000000..399c0b73a5a --- /dev/null +++ b/homeassistant/components/suez_water/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: no subscription to api + dependency-transparency: done + action-setup: + status: exempt + comment: no service action + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: no service action + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: done + action-exceptions: + status: exempt + comment: no service action + reauthentication-flow: todo + parallel-updates: + status: exempt + comment: no service action and coordinator updates + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: missing user/password + docs-configuration-parameters: + status: exempt + comment: no configuration option + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: + status: exempt + comment: api only, nothing on local network to discover services + stale-devices: + status: exempt + comment: one device only + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: one device only + discovery-update-info: + status: exempt + comment: fixed api + repair-issues: + status: exempt + comment: No repair issues to be raised + docs-use-cases: done + docs-supported-devices: todo + docs-supported-functions: done + docs-data-update: + status: todo + comment: make it clearer + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 2ba699a9af1..e4e53dd7f6d 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN -from .coordinator import SuezWaterCoordinator, SuezWaterData +from .coordinator import SuezWaterConfigEntry, SuezWaterCoordinator, SuezWaterData @dataclass(frozen=True, kw_only=True) @@ -53,11 +52,11 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SuezWaterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data counter_id = entry.data[CONF_COUNTER_ID] async_add_entities( diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index 6be2affab97..be2d4849e76 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -5,15 +5,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "counter_id": "Counter id" - } + "counter_id": "Meter id" + }, + "data_description": { + "username": "Enter your login associated with your {tout_sur_mon_eau} account", + "password": "Enter your password associated with your {tout_sur_mon_eau} account", + "counter_id": "Enter your meter id (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information" + }, + "description": "Connect your suez water {tout_sur_mon_eau} account to retrieve your water consumption" } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "counter_not_found": "Could not find counter id automatically" + "counter_not_found": "Could not find meter id automatically" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 8f6f3098ee8..f42f5450462 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType # The sensor platform is pre-imported here to ensure @@ -23,6 +26,8 @@ from .entity import Sun, SunConfigEntry CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track the state of the sun.""" @@ -42,7 +47,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool: """Set up from a config entry.""" - entry.runtime_data = sun = Sun(hass) + sun = Sun(hass) + component = EntityComponent[Sun](_LOGGER, DOMAIN, hass) + await component.async_add_entities([sun]) + entry.runtime_data = sun entry.async_on_unload(sun.remove_listeners) await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True @@ -53,6 +61,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool if unload_ok := await hass.config_entries.async_unload_platforms( entry, [Platform.SENSOR] ): - sun = entry.runtime_data - hass.states.async_remove(sun.entity_id) + await entry.runtime_data.async_remove() return unload_ok diff --git a/homeassistant/components/sun/entity.py b/homeassistant/components/sun/entity.py index 10d328afde7..925845c8b4d 100644 --- a/homeassistant/components/sun/entity.py +++ b/homeassistant/components/sun/entity.py @@ -100,9 +100,6 @@ class Sun(Entity): _attr_name = "Sun" entity_id = ENTITY_ID - # This entity is legacy and does not have a platform. - # We can't fix this easily without breaking changes. - _no_platform_reported = True location: Location elevation: Elevation @@ -122,18 +119,16 @@ class Sun(Entity): self.hass = hass self.phase: str | None = None - # This is normally done by async_internal_added_to_hass which is not called - # for sun because sun has no platform - self._state_info = { - "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined] - } - self._config_listener: CALLBACK_TYPE | None = None self._update_events_listener: CALLBACK_TYPE | None = None self._update_sun_position_listener: CALLBACK_TYPE | None = None self._config_listener = self.hass.bus.async_listen( EVENT_CORE_CONFIG_UPDATE, self.update_location ) + + async def async_added_to_hass(self) -> None: + """Update after entity has been added.""" + await super().async_added_to_hass() self.update_location(initial=True) @callback diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 91d3a4d119a..858379e71df 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -46,7 +46,6 @@ class FanSwitch(BaseToggleEntity, FanEntity): """Represents a Switch as a Fan.""" _attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - _enable_turn_on_off_backwards_compatibility = False @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 7ec0ad4d88b..d946ed1761b 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -90,7 +90,6 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate _attr_fan_modes = SUPPORTED_FAN_MODES _attr_target_temperature_step = 1 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 836ba1bd4f3..927ad5120c7 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -8,17 +8,13 @@ from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ColorMode, LightEntity, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -50,8 +46,8 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): """Initialize the Switchbot light.""" super().__init__(coordinator) device = self._device - self._attr_min_mireds = color_temperature_kelvin_to_mired(device.max_temp) - self._attr_max_mireds = color_temperature_kelvin_to_mired(device.min_temp) + self._attr_max_color_temp_kelvin = device.max_temp + self._attr_min_color_temp_kelvin = device.min_temp self._attr_supported_color_modes = { SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes } @@ -64,7 +60,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): self._attr_is_on = self._device.on self._attr_brightness = max(0, min(255, round(device.brightness * 2.55))) if device.color_mode == SwitchBotColorMode.COLOR_TEMP: - self._attr_color_temp = color_temperature_kelvin_to_mired(device.color_temp) + self._attr_color_temp_kelvin = device.color_temp self._attr_color_mode = ColorMode.COLOR_TEMP return self._attr_rgb_color = device.rgb @@ -77,10 +73,9 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): if ( self.supported_color_modes and ColorMode.COLOR_TEMP in self.supported_color_modes - and ATTR_COLOR_TEMP in kwargs + and ATTR_COLOR_TEMP_KELVIN in kwargs ): - color_temp = kwargs[ATTR_COLOR_TEMP] - kelvin = max(2700, min(6500, color_temperature_mired_to_kelvin(color_temp))) + kelvin = max(2700, min(6500, kwargs[ATTR_COLOR_TEMP_KELVIN])) await self._device.set_color_temp(brightness, kelvin) return if ATTR_RGB_COLOR in kwargs: diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index cd60313f37a..4e05e9e9a1e 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -79,8 +79,9 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): _attr_hvac_mode = HVACMode.FAN_ONLY _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 21 + _attr_target_temperature_step = 1 + _attr_precision = 1 _attr_name = None - _enable_turn_on_off_backwards_compatibility = False async def _do_send_command( self, @@ -97,7 +98,7 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): ) await self.send_api_command( AirConditionerCommands.SET_ALL, - parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", + parameters=f"{int(new_temperature)},{new_mode},{new_fan_speed},on", ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index f9236507037..2d2a1783d73 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -5,13 +5,8 @@ from typing import Any from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -43,17 +38,17 @@ async def async_setup_entry( ) -VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, str] = { - "StandBy": STATE_IDLE, - "Clearing": STATE_CLEANING, - "Paused": STATE_PAUSED, - "GotoChargeBase": STATE_RETURNING, - "Charging": STATE_DOCKED, - "ChargeDone": STATE_DOCKED, - "Dormant": STATE_IDLE, - "InTrouble": STATE_ERROR, - "InRemoteControl": STATE_CLEANING, - "InDustCollecting": STATE_DOCKED, +VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, VacuumActivity] = { + "StandBy": VacuumActivity.IDLE, + "Clearing": VacuumActivity.CLEANING, + "Paused": VacuumActivity.PAUSED, + "GotoChargeBase": VacuumActivity.RETURNING, + "Charging": VacuumActivity.DOCKED, + "ChargeDone": VacuumActivity.DOCKED, + "Dormant": VacuumActivity.IDLE, + "InTrouble": VacuumActivity.ERROR, + "InRemoteControl": VacuumActivity.CLEANING, + "InDustCollecting": VacuumActivity.DOCKED, } VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = { @@ -114,7 +109,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): self._attr_available = self.coordinator.data.get("onlineStatus") == "online" switchbot_state = str(self.coordinator.data.get("workingStatus")) - self._attr_state = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) + self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) self.async_write_ha_state() diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index f2d4fb60252..5285e7549ef 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -83,7 +83,6 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): """Representation of a Switcher climate entity.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 7d3ec0e4af0..513b786a033 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -2,10 +2,8 @@ from __future__ import annotations -import logging from typing import Any, cast -from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter from homeassistant.components.cover import ( @@ -16,7 +14,6 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,8 +21,6 @@ from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity -_LOGGER = logging.getLogger(__name__) - API_SET_POSITON = "set_position" API_STOP = "stop_shutter" @@ -92,32 +87,6 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity): data.direction[self._cover_id] == ShutterDirection.SHUTTER_UP ) - async def _async_call_api(self, api: str, *args: Any) -> None: - """Call Switcher API.""" - _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - self.coordinator.token, - ) as swapi: - response = await getattr(swapi, api)(*args) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - self.coordinator.last_update_success = False - self.async_write_ha_state() - raise HomeAssistantError( - f"Call api for {self.name} failed, api: '{api}', " - f"args: {args}, response/error: {response or error}" - ) - async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._async_call_api(API_SET_POSITON, 0, self._cover_id) diff --git a/homeassistant/components/switcher_kis/entity.py b/homeassistant/components/switcher_kis/entity.py index 12bde521377..e24f59a4a1c 100644 --- a/homeassistant/components/switcher_kis/entity.py +++ b/homeassistant/components/switcher_kis/entity.py @@ -1,11 +1,19 @@ """Base class for Switcher entities.""" +import logging +from typing import Any + +from aioswitcher.api import SwitcherApi, SwitcherBaseResponse + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import SwitcherDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]): """Base class for Switcher entities.""" @@ -18,3 +26,29 @@ class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) + + async def _async_call_api(self, api: str, *args: Any) -> None: + """Call Switcher API.""" + _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + response: SwitcherBaseResponse | None = None + error = None + + try: + async with SwitcherApi( + self.coordinator.data.device_type, + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, + self.coordinator.token, + ) as swapi: + response = await getattr(swapi, api)(*args) + except (TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + self.coordinator.last_update_success = False + self.async_write_ha_state() + raise HomeAssistantError( + f"Call api for {self.name} failed, api: '{api}', " + f"args: {args}, response/error: {response or error}" + ) diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index b2ee624dbc5..75156044efa 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -2,16 +2,13 @@ from __future__ import annotations -import logging from typing import Any, cast -from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, DeviceState, SwitcherLight from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,8 +16,6 @@ from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity -_LOGGER = logging.getLogger(__name__) - API_SET_LIGHT = "set_light" @@ -79,32 +74,6 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity): data = cast(SwitcherLight, self.coordinator.data) return bool(data.light[self._light_id] == DeviceState.ON) - async def _async_call_api(self, api: str, *args: Any) -> None: - """Call Switcher API.""" - _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - self.coordinator.token, - ) as swapi: - response = await getattr(swapi, api)(*args) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - self.coordinator.last_update_success = False - self.async_write_ha_state() - raise HomeAssistantError( - f"Call api for {self.name} failed, api: '{api}', " - f"args: {args}, response/error: {response or error}" - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 987dac65077..d0731c5ae3b 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switcher_kis", "iot_class": "local_push", "loggers": ["aioswitcher"], - "requirements": ["aioswitcher==5.0.0"], + "requirements": ["aioswitcher==5.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 7d14620c1aa..ba0a99b4089 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aioswitcher.api import Command, SwitcherApi, SwitcherBaseResponse +from aioswitcher.api import Command from aioswitcher.device import DeviceCategory, DeviceState import voluptuous as vol @@ -96,35 +96,6 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity): self.control_result = None self.async_write_ha_state() - async def _async_call_api(self, api: str, *args: Any) -> None: - """Call Switcher API.""" - _LOGGER.debug( - "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args - ) - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - ) as swapi: - response = await getattr(swapi, api)(*args) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - _LOGGER.error( - "Call api for %s failed, api: '%s', args: %s, response/error: %s", - self.coordinator.name, - api, - args, - response or error, - ) - self.coordinator.last_update_success = False - @property def is_on(self) -> bool: """Return True if entity is on.""" diff --git a/homeassistant/components/system_log/strings.json b/homeassistant/components/system_log/strings.json index ed1ca79fe07..db71cd6ace4 100644 --- a/homeassistant/components/system_log/strings.json +++ b/homeassistant/components/system_log/strings.json @@ -1,8 +1,8 @@ { "services": { "clear": { - "name": "Clear all", - "description": "Clears all log entries." + "name": "Clear", + "description": "Deletes all log entries." }, "write": { "name": "Write", diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 21a09086d46..5a81e951293 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -269,7 +269,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_name = None _attr_translation_key = DOMAIN _available = False - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 652d51f0261..b0c00c888b7 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.17.7"] + "requirements": ["python-tado==0.17.6"] } diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 95efae3d386..47c1d14ce60 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -106,7 +106,6 @@ class TagStore(Store[collection.SerializedStorageCollection]): for tag in data["items"]: # Copy name in tag store to the entity registry _create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME)) - tag["migrated"] = True if old_major_version == 1 and old_minor_version < 3: # Version 1.3 removes tag_id from the store for tag in data["items"]: @@ -178,10 +177,7 @@ class TagStorageCollection(collection.DictStorageCollection): We don't store the name, it's stored in the entity registry. """ - # Preserve the name of migrated entries to allow downgrading to 2024.5 - # without losing tag names. This can be removed in HA Core 2025.1. - migrated = item_id in self.data and "migrated" in self.data[item_id] - return {k: v for k, v in item.items() if k != CONF_NAME or migrated} + return {k: v for k, v in item.items() if k != CONF_NAME} class TagDictStorageCollectionWebsocket( diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 6f1a234e94a..b191d78f2a6 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import TailwindDataUpdateCoordinator -from .typing import TailwindConfigEntry +from .coordinator import TailwindConfigEntry, TailwindDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.NUMBER] @@ -39,6 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TailwindConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TailwindConfigEntry) -> bool: """Unload Tailwind config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 0ce0b4bd964..d2f8e1e2ced 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -16,8 +16,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity -from .typing import TailwindConfigEntry @dataclass(kw_only=True, frozen=True) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index 2a675bbfdf7..edff3434866 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -19,8 +19,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .coordinator import TailwindConfigEntry from .entity import TailwindEntity -from .typing import TailwindConfigEntry @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 4d1b4af74c9..770751ccc3b 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -18,11 +18,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator] + class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]): """Class to manage fetching Tailwind data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: TailwindConfigEntry) -> None: """Initialize the coordinator.""" self.tailwind = Tailwind( host=entry.data[CONF_HOST], @@ -32,6 +34,7 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]) super().__init__( hass, LOGGER, + config_entry=entry, name=f"{DOMAIN}_{entry.data[CONF_HOST]}", update_interval=timedelta(seconds=5), ) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 116fb4a9e6c..8ea1c7d4f6d 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -23,8 +23,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, LOGGER +from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity -from .typing import TailwindConfigEntry async def async_setup_entry( diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py index 5d681356647..b7a51b56775 100644 --- a/homeassistant/components/tailwind/diagnostics.py +++ b/homeassistant/components/tailwind/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from .typing import TailwindConfigEntry +from .coordinator import TailwindConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 705f591785f..7ad43c929a7 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.2.4"], + "requirements": ["gotailwind==0.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 0ff1f444280..b67df9a6a25 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -15,8 +15,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .coordinator import TailwindConfigEntry from .entity import TailwindEntity -from .typing import TailwindConfigEntry @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tailwind/typing.py b/homeassistant/components/tailwind/typing.py deleted file mode 100644 index 514a94a8e78..00000000000 --- a/homeassistant/components/tailwind/typing.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Typings for the Tailwind integration.""" - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import TailwindDataUpdateCoordinator - -type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator] diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index 15664201d99..e927bd6ad72 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -72,7 +72,6 @@ class TasmotaFan( ) _fan_speed = tasmota_const.FAN_SPEED_MEDIUM _tasmota_entity: tasmota_fan.TasmotaFan - _enable_turn_on_off_backwards_compatibility = False def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota fan.""" diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index 9b69ee60524..a06e77eceb1 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -18,7 +18,7 @@ from hatasmota.models import DiscoveryHashType from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -32,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -199,19 +200,27 @@ class TasmotaLight( return self._color_mode @property - def color_temp(self) -> int | None: - """Return the color temperature in mired.""" - return self._color_temp + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return ( + color_util.color_temperature_mired_to_kelvin(self._color_temp) + if self._color_temp + else None + ) @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._tasmota_entity.min_mireds + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self._tasmota_entity.min_mireds + ) @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self._tasmota_entity.max_mireds + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self._tasmota_entity.max_mireds + ) @property def effect(self) -> str | None: @@ -255,8 +264,13 @@ class TasmotaLight( if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes): attributes["brightness"] = scale_brightness(kwargs[ATTR_BRIGHTNESS]) - if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in supported_color_modes: - attributes["color_temp"] = int(kwargs[ATTR_COLOR_TEMP]) + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and ColorMode.COLOR_TEMP in supported_color_modes + ): + attributes["color_temp"] = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_EFFECT in kwargs: attributes["effect"] = kwargs[ATTR_EFFECT] diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index b586db7c2a7..94d3f0b6831 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TedeeBinarySensorEntityDescription( diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 90f76317fff..d61e7360dc4 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TedeeSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 8ecef8539d3..e6cc377bc26 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -235,8 +235,12 @@ def _validate_unit(options: dict[str, Any]) -> None: and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): + # Sort twice to make sure strings with same case-insensitive order of + # letters are sorted consistently still. sorted_units = sorted( - [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + sorted( + [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + ), key=str.casefold, ) if len(sorted_units) == 1: diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index cedd7d0d725..7720ef7e1b3 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -124,7 +124,6 @@ class TemplateFan(TemplateEntity, FanEntity): """A template fan component.""" _attr_should_poll = False - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index cae6c0cebc1..0654a42406a 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -39,6 +39,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import color as color_util from .const import DOMAIN from .template_entity import ( @@ -77,6 +78,9 @@ CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_WHITE_VALUE_ACTION = "set_white_value" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" +DEFAULT_MIN_MIREDS = 153 +DEFAULT_MAX_MIREDS = 500 + LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( @@ -262,25 +266,27 @@ class LightTemplate(TemplateEntity, LightEntity): return self._brightness @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self._temperature + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if self._temperature is None: + return None + return color_util.color_temperature_mired_to_kelvin(self._temperature) @property - def max_mireds(self) -> int: - """Return the max mireds value in mireds.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if self._max_mireds is not None: - return self._max_mireds + return color_util.color_temperature_mired_to_kelvin(self._max_mireds) - return super().max_mireds + return super().min_color_temp_kelvin @property - def min_mireds(self) -> int: - """Return the min mireds value in mireds.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if self._min_mireds is not None: - return self._min_mireds + return color_util.color_temperature_mired_to_kelvin(self._min_mireds) - return super().min_mireds + return super().max_color_temp_kelvin @property def hs_color(self) -> tuple[float, float] | None: @@ -447,13 +453,16 @@ class LightTemplate(TemplateEntity, LightEntity): self._brightness = kwargs[ATTR_BRIGHTNESS] optimistic_set = True - if self._temperature_template is None and ATTR_COLOR_TEMP in kwargs: + if self._temperature_template is None and ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) _LOGGER.debug( "Optimistically setting color temperature to %s", - kwargs[ATTR_COLOR_TEMP], + color_temp, ) self._color_mode = ColorMode.COLOR_TEMP - self._temperature = kwargs[ATTR_COLOR_TEMP] + self._temperature = color_temp if self._hs_template is None and self._color_template is None: self._hs_color = None if self._rgb_template is None: @@ -544,8 +553,10 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ATTR_COLOR_TEMP in kwargs and self._temperature_script: - common_params["color_temp"] = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temperature_script: + common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) await self.async_run_script( self._temperature_script, @@ -756,7 +767,9 @@ class LightTemplate(TemplateEntity, LightEntity): self._temperature = None return temperature = int(render) - if self.min_mireds <= temperature <= self.max_mireds: + min_mireds = self._min_mireds or DEFAULT_MIN_MIREDS + max_mireds = self._max_mireds or DEFAULT_MAX_MIREDS + if min_mireds <= temperature <= max_mireds: self._temperature = temperature else: _LOGGER.error( @@ -766,8 +779,8 @@ class LightTemplate(TemplateEntity, LightEntity): ), temperature, self.entity_id, - self.min_mireds, - self.max_mireds, + min_mireds, + max_mireds, ) self._temperature = None except ValueError: diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index d7bb30dbba0..f194154a50c 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - STATE_ON, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError @@ -89,7 +88,7 @@ class TemplateLock(TemplateEntity, LockEntity): super().__init__( hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id ) - self._state: str | bool | LockState | None = None + self._state: LockState | None = None name = self._attr_name assert name self._state_template = config.get(CONF_VALUE_TEMPLATE) @@ -107,7 +106,7 @@ class TemplateLock(TemplateEntity, LockEntity): @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._state in ("true", STATE_ON, LockState.LOCKED) + return self._state == LockState.LOCKED @property def is_jammed(self) -> bool: @@ -130,7 +129,7 @@ class TemplateLock(TemplateEntity, LockEntity): return self._state == LockState.OPEN @callback - def _update_state(self, result): + def _update_state(self, result: str | TemplateError) -> None: """Update the state from the template.""" super()._update_state(result) if isinstance(result, TemplateError): @@ -142,7 +141,23 @@ class TemplateLock(TemplateEntity, LockEntity): return if isinstance(result, str): - self._state = result.lower() + if result.lower() in ( + "true", + "on", + "locked", + ): + self._state = LockState.LOCKED + elif result.lower() in ( + "false", + "off", + "unlocked", + ): + self._state = LockState.UNLOCKED + else: + try: + self._state = LockState(result.lower()) + except ValueError: + self._state = None return self._state = None @@ -189,7 +204,7 @@ class TemplateLock(TemplateEntity, LockEntity): self._raise_template_error_if_available() if self._optimistic: - self._state = True + self._state = LockState.LOCKED self.async_write_ha_state() tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} @@ -205,7 +220,7 @@ class TemplateLock(TemplateEntity, LockEntity): self._raise_template_error_if_available() if self._optimistic: - self._state = False + self._state = LockState.UNLOCKED self.async_write_ha_state() tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1d021bcb571..19029cc708b 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -17,13 +17,8 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -58,12 +53,12 @@ CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" _VALID_STATES = [ - STATE_CLEANING, - STATE_DOCKED, - STATE_PAUSED, - STATE_IDLE, - STATE_RETURNING, - STATE_ERROR, + VacuumActivity.CLEANING, + VacuumActivity.DOCKED, + VacuumActivity.PAUSED, + VacuumActivity.IDLE, + VacuumActivity.RETURNING, + VacuumActivity.ERROR, ] VACUUM_SCHEMA = vol.All( @@ -202,7 +197,7 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" return self._state diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 1ddfa188c0a..16de386b15d 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.1.3", + "numpy==2.2.0", "Pillow==11.0.0" ] } diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index e7030b568b3..bc837aa4cac 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -34,7 +34,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from .config_flow import OAuth2FlowHandler from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( TeslaFleetEnergySiteInfoCoordinator, @@ -42,7 +41,6 @@ from .coordinator import ( TeslaFleetVehicleDataCoordinator, ) from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData -from .oauth import TeslaSystemImplementation PLATFORMS: Final = [ Platform.BINARY_SENSOR, @@ -73,11 +71,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - scopes: list[Scope] = [Scope(s) for s in token["scp"]] region: str = token["ou_code"].lower() - OAuth2FlowHandler.async_register_implementation( - hass, - TeslaSystemImplementation(hass), - ) - implementation = await async_get_config_entry_implementation(hass, entry) oauth_session = OAuth2Session(hass, entry, implementation) refresh_lock = asyncio.Lock() diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 9a1533a688f..06e9c9d7c64 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -74,7 +74,6 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes = ["off", "keep", "dog", "camp"] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -220,7 +219,7 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn _attr_max_temp = COP_LEVELS["High"] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False def __init__( diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index ca36c6f511b..feeb5e74ca6 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, LOGGER -from .oauth import TeslaSystemImplementation class OAuth2FlowHandler( @@ -31,11 +30,6 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow start.""" - self.async_register_implementation( - self.hass, - TeslaSystemImplementation(self.hass), - ) - return await super().async_step_user() async def async_oauth_create_entry( diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 53e34092326..9b3baf49bfb 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -21,6 +21,7 @@ SCOPES = [ Scope.OPENID, Scope.OFFLINE_ACCESS, Scope.VEHICLE_DEVICE_DATA, + Scope.VEHICLE_LOCATION, Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS, Scope.ENERGY_DEVICE_DATA, @@ -32,6 +33,8 @@ MODELS = { "3": "Model 3", "X": "Model X", "Y": "Model Y", + "C": "Cybertruck", + "T": "Tesla Semi", } diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index f27929032d7..95062a8f856 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.8.4"] + "requirements": ["tesla-fleet-api==0.8.5"] } diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py index 8b43460436b..b25c5216009 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -1,8 +1,5 @@ """Provide oauth implementations for the Tesla Fleet integration.""" -import base64 -import hashlib -import secrets from typing import Any from homeassistant.components.application_credentials import ( @@ -11,59 +8,8 @@ from homeassistant.components.application_credentials import ( ClientCredential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow -from .const import AUTHORIZE_URL, CLIENT_ID, DOMAIN, SCOPES, TOKEN_URL - - -class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): - """Tesla Fleet API open source Oauth2 implementation.""" - - code_verifier: str - code_challenge: str - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize open source Oauth2 implementation.""" - - # Setup PKCE - self.code_verifier = secrets.token_urlsafe(32) - hashed_verifier = hashlib.sha256(self.code_verifier.encode()).digest() - self.code_challenge = ( - base64.urlsafe_b64encode(hashed_verifier).decode().replace("=", "") - ) - super().__init__( - hass, - DOMAIN, - CLIENT_ID, - "", - AUTHORIZE_URL, - TOKEN_URL, - ) - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Built-in open source client ID" - - @property - def extra_authorize_data(self) -> dict[str, Any]: - """Extra data that needs to be appended to the authorize url.""" - return { - "prompt": "login", - "scope": " ".join(SCOPES), - "code_challenge": self.code_challenge, # PKCE - } - - async def async_resolve_external_data(self, external_data: Any) -> dict: - """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - "code_verifier": self.code_verifier, # PKCE - } - ) +from .const import AUTHORIZE_URL, SCOPES, TOKEN_URL class TeslaUserImplementation(AuthImplementation): diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index aa1d2b42660..0b61120877a 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -253,7 +253,6 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None """Handle vehicle data from the stream.""" if "vehicle_data" in data: LOGGER.debug("Streaming received vehicle data from %s", vin) - coordinator.updated_once = True coordinator.async_set_updated_data(flatten(data["vehicle_data"])) elif "state" in data: LOGGER.debug("Streaming received state from %s", vin) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index b51a67a0b4e..29ebfea4db1 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -223,15 +223,12 @@ class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorE def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - if self.coordinator.updated_once: - if self._value is None: - self._attr_available = False - self._attr_is_on = None - else: - self._attr_available = True - self._attr_is_on = self.entity_description.is_on(self._value) - else: + if self._value is None: + self._attr_available = False self._attr_is_on = None + else: + self._attr_available = True + self._attr_is_on = self.entity_description.is_on(self._value) class TeslemetryEnergyLiveBinarySensorEntity( diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 5e933d1dbce..95b769a1c2d 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -74,7 +74,6 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes = ["off", "keep", "dog", "camp"] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -97,9 +96,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" value = self.get("climate_state_is_climate_on") - if value is None: - self._attr_hvac_mode = None - elif value: + if value: self._attr_hvac_mode = HVACMode.HEAT_COOL else: self._attr_hvac_mode = HVACMode.OFF @@ -209,7 +206,7 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn _attr_max_temp = COP_LEVELS["High"] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False def __init__( diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index f37d0613de9..e7232d0f87c 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -6,18 +6,16 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( - Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, - VehicleOffline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslemetryState +from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten VEHICLE_INTERVAL = timedelta(seconds=30) @@ -39,7 +37,6 @@ ENDPOINTS = [ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" - updated_once: bool last_active: datetime def __init__( @@ -54,63 +51,24 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.api = api self.data = flatten(product) - self.updated_once = False self.last_active = datetime.now() async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Teslemetry API.""" - self.update_interval = VEHICLE_INTERVAL - try: - if self.data["state"] != TeslemetryState.ONLINE: - response = await self.api.vehicle() - self.data["state"] = response["response"]["state"] - - if self.data["state"] != TeslemetryState.ONLINE: - return self.data - - response = await self.api.vehicle_data(endpoints=ENDPOINTS) - data = response["response"] - - except VehicleOffline: - self.data["state"] = TeslemetryState.OFFLINE - return self.data - except InvalidToken as e: - raise ConfigEntryAuthFailed from e - except SubscriptionRequired as e: + data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - self.updated_once = True - - if self.api.pre2021 and data["state"] == TeslemetryState.ONLINE: - # Handle pre-2021 vehicles which cannot sleep by themselves - if ( - data["charge_state"].get("charging_state") == "Charging" - or data["vehicle_state"].get("is_user_present") - or data["vehicle_state"].get("sentry_mode") - ): - # Vehicle is active, reset timer - self.last_active = datetime.now() - else: - elapsed = datetime.now() - self.last_active - if elapsed > timedelta(minutes=20): - # Vehicle didn't sleep, try again in 15 minutes - self.last_active = datetime.now() - elif elapsed > timedelta(minutes=15): - # Let vehicle go to sleep now - self.update_interval = VEHICLE_WAIT - return flatten(data) class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Teslemetry API.""" - updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( @@ -126,7 +84,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) try: data = (await self.api.live_status())["response"] - except (InvalidToken, Forbidden, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e @@ -142,8 +100,6 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( @@ -160,7 +116,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) try: data = (await self.api.site_info())["response"] - except (InvalidToken, Forbidden, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e @@ -171,8 +127,6 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( @@ -188,13 +142,11 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] - except (InvalidToken, Forbidden, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - self.updated_once = True - # Add all time periods together output = {key: 0 for key in ENERGY_HISTORY_FIELDS} for period in data.get("time_series", []): diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 8775da931d5..d14ef385b9c 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -73,9 +73,6 @@ class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): # All closed set to closed elif CLOSED == fd == fp == rd == rp: self._attr_is_closed = True - # Otherwise, set to unknown - else: - self._attr_is_closed = None async def async_open_cover(self, **kwargs: Any) -> None: """Vent windows.""" diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 0a7a557ed88..4600391145b 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -82,8 +82,6 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): def _async_update_attrs(self) -> None: """Update entity attributes.""" - if self._value is None: - self._attr_is_locked = None self._attr_is_locked = self._value == ENGAGED async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index fc82dea6445..3736d76bf36 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.8.4", "teslemetry-stream==0.4.2"] + "requirements": ["tesla-fleet-api==0.8.5", "teslemetry-stream==0.4.2"] } diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 192e2b194a8..baf1d80ac6c 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -90,10 +90,12 @@ async def async_setup_entry( ) for description in SEAT_HEATER_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles + if description.key in vehicle.coordinator.data ), ( TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles + if vehicle.coordinator.data.get("climate_state_steering_wheel_heater") ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) @@ -137,7 +139,7 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): """Handle updated data from the coordinator.""" self._attr_available = self.entity_description.available_fn(self) value = self._value - if value is None: + if not isinstance(value, int): self._attr_current_option = None else: self._attr_current_option = self._attr_options[value] @@ -182,7 +184,7 @@ class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): """Handle updated data from the coordinator.""" value = self._value - if value is None: + if not isinstance(value, int): self._attr_current_option = None else: self._attr_current_option = self._attr_options[value] diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 91ef3074bae..6a1cff4c5da 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -102,6 +102,7 @@ async def async_setup_entry( ) for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS + if description.key in vehicle.coordinator.data ), ( TeslemetryChargeSwitchEntity( @@ -150,10 +151,7 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self._value is None: - self._attr_is_on = None - else: - self._attr_is_on = bool(self._value) + self._attr_is_on = bool(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index e0649432e05..1d26926aeaa 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -60,7 +60,6 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): TessieClimateKeeper.DOG, TessieClimateKeeper.CAMP, ] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index cab9f4c706d..2b8ae924fe3 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.4"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.5"] } diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 81517a6f1f5..e3aa9060787 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -88,7 +88,6 @@ class TfiacClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hass, client): """Init class.""" diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 5033cda11d0..938e96b9917 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -4,7 +4,6 @@ from __future__ import annotations import datetime as dt from datetime import datetime -from functools import partial from typing import Any, Final import voluptuous as vol @@ -33,8 +32,8 @@ SERVICE_SCHEMA: Final = vol.Schema( ) -async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResponse: - tibber_connection = hass.data[DOMAIN] +async def __get_prices(call: ServiceCall) -> ServiceResponse: + tibber_connection = call.hass.data[DOMAIN] start = __get_date(call.data.get(ATTR_START), "start") end = __get_date(call.data.get(ATTR_END), "end") @@ -94,7 +93,7 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, PRICE_SERVICE_NAME, - partial(__get_prices, hass=hass), + __get_prices, schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 064ec81df1d..4fd80f565a2 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -34,33 +34,33 @@ "services": { "start": { "name": "[%key:common::action::start%]", - "description": "Starts a timer.", + "description": "Starts a timer or restarts it with a provided duration.", "fields": { "duration": { "name": "Duration", - "description": "Duration the timer requires to finish. [optional]." + "description": "Custom duration to restart the timer with." } } }, "pause": { "name": "[%key:common::action::pause%]", - "description": "Pauses a timer." + "description": "Pauses a running timer, retaining the remaining duration for later continuation." }, "cancel": { "name": "Cancel", - "description": "Cancels a timer." + "description": "Resets a timer's duration to the last known initial value without firing the timer finished event." }, "finish": { "name": "Finish", - "description": "Finishes a timer." + "description": "Finishes a running timer earlier than scheduled." }, "change": { "name": "Change", - "description": "Changes a timer.", + "description": "Changes a timer by adding or subtracting a given duration.", "fields": { "duration": { "name": "Duration", - "description": "Duration to add or subtract to the running timer." + "description": "Duration to add to or subtract from the running timer." } } }, diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 8c5176b3e4e..5e6428525c1 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -60,7 +60,6 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): ) _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 9b62346a83b..9e48778b507 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -29,7 +29,6 @@ class ToloFan(ToloSaunaCoordinatorEntity, FanEntity): _attr_translation_key = "fan" _attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 365706ba4fd..0c2e5b9b232 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -52,7 +52,6 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 87ec14621d9..33306a7adba 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.5"] + "requirements": ["total-connect-client==2024.12"] } diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml new file mode 100644 index 00000000000..a8e5b60f7ee --- /dev/null +++ b/homeassistant/components/totalconnect/quality_scale.yaml @@ -0,0 +1,62 @@ +rules: + # Bronze + config-flow: todo + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: todo + runtime-data: todo + test-before-setup: todo + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: todo + dependency-transparency: done + action-setup: todo + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: todo + action-exceptions: todo + reauthentication-flow: done + parallel-updates: todo + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: todo + stale-devices: todo + diagnostics: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: todo + docs-use-cases: done + + # stopped here.... + docs-supported-devices: todo + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: done + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index 7b14404ee34..e9d27341cb7 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -70,7 +70,6 @@ class Touchline(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, touchline_thermostat): """Initialize the Touchline device.""" diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index 0bd25d9f80c..75a6599959d 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -67,7 +67,6 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS # This disables the warning for async_turn_{on,off}, can be removed later. - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index f90eadbc531..64ad01eb671 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -64,7 +64,6 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 3f19f50cdb6..6ce46c0d488 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.8.0"] + "requirements": ["python-kasa[speedups]==0.8.1"] } diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index a92efa660b6..a3c1893267c 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, PERCENTAGE, EntityCategory, + UnitOfEnergy, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback @@ -127,7 +128,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_CALORIES, translation_key="calories", - native_unit_of_measurement="kcal", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index 75616607ee8..3f45ee3e1eb 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -69,7 +69,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): # ... with step size 1 # 50 = Max _attr_speed_count = ATTR_MAX_FAN_STEPS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index b0bf6d24019..a71691e6e90 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -9,7 +9,7 @@ from pytradfri.command import Command from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ColorMode, @@ -87,8 +87,16 @@ class TradfriLight(TradfriBaseEntity, LightEntity): self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) if self._device_control: - self._attr_min_mireds = self._device_control.min_mireds - self._attr_max_mireds = self._device_control.max_mireds + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + self._device_control.min_mireds + ) + ) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + self._device_control.max_mireds + ) + ) def _refresh(self) -> None: """Refresh the device.""" @@ -118,11 +126,11 @@ class TradfriLight(TradfriBaseEntity, LightEntity): return cast(int, self._device_data.dimmer) @property - def color_temp(self) -> int | None: - """Return the color temp value in mireds.""" - if not self._device_data: + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if not self._device_data or not (color_temp := self._device_data.color_temp): return None - return cast(int, self._device_data.color_temp) + return color_util.color_temperature_mired_to_kelvin(color_temp) @property def hs_color(self) -> tuple[float, float] | None: @@ -191,18 +199,19 @@ class TradfriLight(TradfriBaseEntity, LightEntity): transition_time = None temp_command = None - if ATTR_COLOR_TEMP in kwargs and ( + if ATTR_COLOR_TEMP_KELVIN in kwargs and ( self._device_control.can_set_temp or self._device_control.can_set_color ): - temp = kwargs[ATTR_COLOR_TEMP] + temp_k = kwargs[ATTR_COLOR_TEMP_KELVIN] # White Spectrum bulb if self._device_control.can_set_temp: - if temp > self.max_mireds: - temp = self.max_mireds - elif temp < self.min_mireds: - temp = self.min_mireds + temp = color_util.color_temperature_kelvin_to_mired(temp_k) + if temp < (min_mireds := self._device_control.min_mireds): + temp = min_mireds + elif temp > (max_mireds := self._device_control.max_mireds): + temp = max_mireds temp_data = { - ATTR_COLOR_TEMP: temp, + "color_temp": temp, "transition_time": transition_time, } temp_command = self._device_control.set_color_temp(**temp_data) @@ -210,7 +219,6 @@ class TradfriLight(TradfriBaseEntity, LightEntity): # Color bulb (CWS) # color_temp needs to be set with hue/saturation elif self._device_control.can_set_color: - temp_k = color_util.color_temperature_mired_to_kelvin(temp) hs_color = color_util.color_temperature_to_hs(temp_k) hue = int(hs_color[0] * (self._device_control.max_hue / 360)) sat = int(hs_color[1] * (self._device_control.max_saturation / 100)) diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index d7981105fd2..85012939fc1 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.1.3"] + "requirements": ["numpy==2.2.0"] } diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 93aaaa40c26..62aa29494e9 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -120,7 +120,6 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): _set_temperature: IntegerTypeData | None = None entity_description: TuyaClimateEntityDescription _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 4a6de1cae09..ffab9efdde8 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -66,7 +66,6 @@ class TuyaFanEntity(TuyaEntity, FanEntity): _speeds: EnumTypeData | None = None _switch: DPCode | None = None _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 060b1f4b7ef..d7dffc16b58 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -10,7 +10,7 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -21,6 +21,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode @@ -49,6 +50,9 @@ DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1), ) +MAX_MIREDS = 500 # 2000 K +MIN_MIREDS = 153 # 6500 K + @dataclass(frozen=True) class TuyaLightEntityDescription(LightEntityDescription): @@ -457,6 +461,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _color_mode: DPCode | None = None _color_temp: IntegerTypeData | None = None _fixed_color_mode: ColorMode | None = None + _attr_min_color_temp_kelvin = 2000 # 500 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds def __init__( self, @@ -532,7 +538,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Turn on or control the light.""" commands = [{"code": self.entity_description.key, "value": True}] - if self._color_temp and ATTR_COLOR_TEMP in kwargs: + if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: if self._color_mode_dpcode: commands += [ { @@ -546,9 +552,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity): "code": self._color_temp.dpcode, "value": round( self._color_temp.remap_value_from( - kwargs[ATTR_COLOR_TEMP], - self.min_mireds, - self.max_mireds, + color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ), + MIN_MIREDS, + MAX_MIREDS, reverse=True, ) ), @@ -560,7 +568,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): or ( ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS - and ATTR_COLOR_TEMP not in kwargs + and ATTR_COLOR_TEMP_KELVIN not in kwargs ) ): if self._color_mode_dpcode: @@ -688,8 +696,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): return round(brightness) @property - def color_temp(self) -> int | None: - """Return the color_temp of the light.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if not self._color_temp: return None @@ -697,9 +705,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity): if temperature is None: return None - return round( + return color_util.color_temperature_mired_to_kelvin( self._color_temp.remap_value_to( - temperature, self.min_mireds, self.max_mireds, reverse=True + temperature, MIN_MIREDS, MAX_MIREDS, reverse=True ) ) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 2e0a154e670..738492102a1 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -7,13 +7,10 @@ from typing import Any from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,29 +21,29 @@ from .entity import EnumTypeData, IntegerTypeData, TuyaEntity TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { - "charge_done": STATE_DOCKED, - "chargecompleted": STATE_DOCKED, - "chargego": STATE_DOCKED, - "charging": STATE_DOCKED, - "cleaning": STATE_CLEANING, - "docking": STATE_RETURNING, - "goto_charge": STATE_RETURNING, - "goto_pos": STATE_CLEANING, - "mop_clean": STATE_CLEANING, - "part_clean": STATE_CLEANING, - "paused": STATE_PAUSED, - "pick_zone_clean": STATE_CLEANING, - "pos_arrived": STATE_CLEANING, - "pos_unarrive": STATE_CLEANING, - "random": STATE_CLEANING, - "sleep": STATE_IDLE, - "smart_clean": STATE_CLEANING, - "smart": STATE_CLEANING, - "spot_clean": STATE_CLEANING, - "standby": STATE_IDLE, - "wall_clean": STATE_CLEANING, - "wall_follow": STATE_CLEANING, - "zone_clean": STATE_CLEANING, + "charge_done": VacuumActivity.DOCKED, + "chargecompleted": VacuumActivity.DOCKED, + "chargego": VacuumActivity.DOCKED, + "charging": VacuumActivity.DOCKED, + "cleaning": VacuumActivity.CLEANING, + "docking": VacuumActivity.RETURNING, + "goto_charge": VacuumActivity.RETURNING, + "goto_pos": VacuumActivity.CLEANING, + "mop_clean": VacuumActivity.CLEANING, + "part_clean": VacuumActivity.CLEANING, + "paused": VacuumActivity.PAUSED, + "pick_zone_clean": VacuumActivity.CLEANING, + "pos_arrived": VacuumActivity.CLEANING, + "pos_unarrive": VacuumActivity.CLEANING, + "random": VacuumActivity.CLEANING, + "sleep": VacuumActivity.IDLE, + "smart_clean": VacuumActivity.CLEANING, + "smart": VacuumActivity.CLEANING, + "spot_clean": VacuumActivity.CLEANING, + "standby": VacuumActivity.IDLE, + "wall_clean": VacuumActivity.CLEANING, + "wall_follow": VacuumActivity.CLEANING, + "zone_clean": VacuumActivity.CLEANING, } @@ -137,12 +134,12 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): return self.device.status.get(DPCode.SUCTION) @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return Tuya vacuum device state.""" if self.device.status.get(DPCode.PAUSE) and not ( self.device.status.get(DPCode.STATUS) ): - return STATE_PAUSED + return VacuumActivity.PAUSED if not (status := self.device.status.get(DPCode.STATUS)): return None return TUYA_STATUS_TO_HA.get(status) diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 2796e9916f1..1359e707601 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -2,53 +2,25 @@ from __future__ import annotations -from datetime import date, timedelta - -from twentemilieu import TwenteMilieu, WasteType import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_HOUSE_LETTER, CONF_HOUSE_NUMBER, CONF_POST_CODE, DOMAIN, LOGGER - -SCAN_INTERVAL = timedelta(seconds=3600) +from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator SERVICE_UPDATE = "update" SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] -type TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator[ - dict[WasteType, list[date]] -] -type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: TwenteMilieuConfigEntry ) -> bool: """Set up Twente Milieu from a config entry.""" - session = async_get_clientsession(hass) - twentemilieu = TwenteMilieu( - post_code=entry.data[CONF_POST_CODE], - house_number=entry.data[CONF_HOUSE_NUMBER], - house_letter=entry.data[CONF_HOUSE_LETTER], - session=session, - ) - - coordinator: TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - update_method=twentemilieu.update, - ) + coordinator = TwenteMilieuDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 8e7452823b7..d163ae4e564 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import TwenteMilieuConfigEntry from .const import WASTE_TYPE_TO_DESCRIPTION +from .coordinator import TwenteMilieuConfigEntry from .entity import TwenteMilieuEntity diff --git a/homeassistant/components/twentemilieu/coordinator.py b/homeassistant/components/twentemilieu/coordinator.py new file mode 100644 index 00000000000..d2cf5a887ef --- /dev/null +++ b/homeassistant/components/twentemilieu/coordinator.py @@ -0,0 +1,49 @@ +"""Data update coordinator for Twente Milieu.""" + +from __future__ import annotations + +from datetime import date + +from twentemilieu import TwenteMilieu, WasteType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DOMAIN, + LOGGER, + SCAN_INTERVAL, +) + +type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator] + + +class TwenteMilieuDataUpdateCoordinator( + DataUpdateCoordinator[dict[WasteType, list[date]]] +): + """Class to manage fetching Twente Milieu data.""" + + def __init__(self, hass: HomeAssistant, entry: TwenteMilieuConfigEntry) -> None: + """Initialize Twente Milieu data update coordinator.""" + self.twentemilieu = TwenteMilieu( + post_code=entry.data[CONF_POST_CODE], + house_number=entry.data[CONF_HOUSE_NUMBER], + house_letter=entry.data[CONF_HOUSE_LETTER], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + async def _async_update_data(self) -> dict[WasteType, list[date]]: + """Fetch Twente Milieu data.""" + return await self.twentemilieu.update() diff --git a/homeassistant/components/twentemilieu/diagnostics.py b/homeassistant/components/twentemilieu/diagnostics.py index 75775303eb6..cb3b411c530 100644 --- a/homeassistant/components/twentemilieu/diagnostics.py +++ b/homeassistant/components/twentemilieu/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import TwenteMilieuConfigEntry +from .coordinator import TwenteMilieuConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py index 0a2473f4524..660dd16288c 100644 --- a/homeassistant/components/twentemilieu/entity.py +++ b/homeassistant/components/twentemilieu/entity.py @@ -7,8 +7,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator from .const import DOMAIN +from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], Entity): diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index a89091948c2..c04c5492a40 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["twentemilieu"], - "requirements": ["twentemilieu==2.1.0"] + "quality_scale": "silver", + "requirements": ["twentemilieu==2.2.0"] } diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml index f8fd813b03d..42ff152cb4d 100644 --- a/homeassistant/components/twentemilieu/quality_scale.yaml +++ b/homeassistant/components/twentemilieu/quality_scale.yaml @@ -6,26 +6,17 @@ rules: This integration does not provide additional actions. appropriate-polling: done brands: done - common-modules: - status: todo - comment: | - The coordinator isn't in the common module yet. + common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - data_description's are missing. + config-flow: done dependency-transparency: done docs-actions: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: - status: todo - comment: | - The introduction can be improved and is missing links to the provider. + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -57,7 +48,7 @@ rules: data), there is no need to implement parallel updates. test-coverage: done integration-owner: done - docs-installation-parameters: todo + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: | @@ -101,16 +92,16 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - docs-use-cases: todo + docs-use-cases: done docs-supported-devices: status: exempt comment: | This is an service, which doesn't integrate with any devices. docs-supported-functions: done - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo - docs-examples: todo + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done # Platinum async-dependency: done diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index f5f91ce7080..4605ede1f87 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -16,8 +16,8 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TwenteMilieuConfigEntry from .const import DOMAIN +from .coordinator import TwenteMilieuConfigEntry from .entity import TwenteMilieuEntity diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json index 7797167ea0b..5c40df1b0c2 100644 --- a/homeassistant/components/twentemilieu/strings.json +++ b/homeassistant/components/twentemilieu/strings.json @@ -7,6 +7,11 @@ "post_code": "Postal code", "house_number": "House number", "house_letter": "House letter/additional" + }, + "data_description": { + "post_code": "The postal code of the address, for example 7500AA", + "house_number": "The house number of the address", + "house_letter": "The house letter or additional information of the address" } } }, diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 6979a016447..22a1782f594 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -7,7 +7,6 @@ from typing import cast from aiohttp.client_exceptions import ClientError, ClientResponseError from twitchAPI.twitch import Twitch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -17,11 +16,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS -from .coordinator import TwitchCoordinator +from .const import OAUTH_SCOPES, PLATFORMS +from .coordinator import TwitchConfigEntry, TwitchCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TwitchConfigEntry) -> bool: """Set up Twitch from a config entry.""" implementation = cast( LocalOAuth2Implementation, @@ -47,18 +46,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.auto_refresh_auth = False await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) - coordinator = TwitchCoordinator(hass, client, session) - + coordinator = TwitchCoordinator(hass, client, session, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TwitchConfigEntry) -> bool: """Unload Twitch config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index c34eeaa5325..c61e80bd2b8 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES +type TwitchConfigEntry = ConfigEntry[TwitchCoordinator] + def chunk_list(lst: list, chunk_size: int) -> list[list]: """Split a list into chunks of chunk_size.""" @@ -44,12 +46,16 @@ class TwitchUpdate: class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): """Class to manage fetching Twitch data.""" - config_entry: ConfigEntry + config_entry: TwitchConfigEntry users: list[TwitchUser] current_user: TwitchUser def __init__( - self, hass: HomeAssistant, twitch: Twitch, session: OAuth2Session + self, + hass: HomeAssistant, + twitch: Twitch, + session: OAuth2Session, + entry: TwitchConfigEntry, ) -> None: """Initialize the coordinator.""" self.twitch = twitch @@ -58,6 +64,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5), + config_entry=entry, ) self.session = session diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index bd5fc509989..b407eae0319 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -4,16 +4,13 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TwitchCoordinator -from .const import DOMAIN -from .coordinator import TwitchUpdate +from .coordinator import TwitchConfigEntry, TwitchCoordinator, TwitchUpdate ATTR_GAME = "game" ATTR_TITLE = "title" @@ -34,11 +31,11 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TwitchConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Initialize entries.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TwitchSensor(coordinator, channel_id) for channel_id in coordinator.data @@ -49,6 +46,8 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity): """Representation of a Twitch channel.""" _attr_translation_key = "channel" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [STATE_OFFLINE, STATE_STREAMING] def __init__(self, coordinator: TwitchCoordinator, channel_id: str) -> None: """Initialize the sensor.""" @@ -82,8 +81,8 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity): ATTR_TITLE: channel.title, ATTR_STARTED_AT: channel.started_at, ATTR_VIEWERS: channel.viewers, + ATTR_SUBSCRIPTION: False, } - resp[ATTR_SUBSCRIPTION] = False if channel.subscribed is not None: resp[ATTR_SUBSCRIPTION] = channel.subscribed resp[ATTR_SUBSCRIPTION_GIFTED] = channel.subscription_gifted diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json index bbe46526c36..7271b81e924 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -16,5 +16,47 @@ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } + }, + "entity": { + "sensor": { + "channel": { + "state": { + "streaming": "Streaming", + "offline": "Offline" + }, + "state_attributes": { + "followers": { + "name": "Followers" + }, + "game": { + "name": "Game" + }, + "title": { + "name": "Title" + }, + "started_at": { + "name": "Started at" + }, + "viewers": { + "name": "Viewers" + }, + "subscribed": { + "name": "Subscribed" + }, + "subscription_is_gifted": { + "name": "Subscription is gifted" + }, + "subscription_tier": { + "name": "Subscription tier" + }, + "following": { + "name": "Following" + }, + "following_since": { + "name": "Following since" + } + } + } + } } } diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 66d0a53284b..ce573592153 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==80"], + "requirements": ["aiounifi==81"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index a40939be917..0b1c03b8dd6 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -90,7 +90,7 @@ def _get_camera_channels( is_default = False # no RTSP enabled use first channel with no stream - if is_default: + if is_default and not camera.is_third_party_camera: _create_rtsp_repair(hass, entry, data, camera) yield camera, camera.channels[0], True else: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e8a8c062800..81ef72ec50d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.5", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.0.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 119fe52756c..35713efdf3d 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import functools from typing import Any, cast from pydantic import ValidationError @@ -88,9 +87,9 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl @callback -def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera: - ref = async_extract_referenced_entity_ids(hass, call) - entity_registry = er.async_get(hass) +def _async_get_ufp_camera(call: ServiceCall) -> Camera: + ref = async_extract_referenced_entity_ids(call.hass, call) + entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() camera_entity = entity_registry.async_get(entity_id) @@ -98,30 +97,27 @@ def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera: assert camera_entity.device_id is not None camera_mac = _async_unique_id_to_mac(camera_entity.unique_id) - instance = _async_get_ufp_instance(hass, camera_entity.device_id) + instance = _async_get_ufp_instance(call.hass, camera_entity.device_id) return cast(Camera, instance.bootstrap.get_device_from_mac(camera_mac)) @callback -def _async_get_protect_from_call( - hass: HomeAssistant, call: ServiceCall -) -> set[ProtectApiClient]: +def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]: return { - _async_get_ufp_instance(hass, device_id) + _async_get_ufp_instance(call.hass, device_id) for device_id in async_extract_referenced_entity_ids( - hass, call + call.hass, call ).referenced_devices } async def _async_service_call_nvr( - hass: HomeAssistant, call: ServiceCall, method: str, *args: Any, **kwargs: Any, ) -> None: - instances = _async_get_protect_from_call(hass, call) + instances = _async_get_protect_from_call(call) try: await asyncio.gather( *(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances) @@ -130,23 +126,23 @@ async def _async_service_call_nvr( raise HomeAssistantError(str(err)) from err -async def add_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: +async def add_doorbell_text(call: ServiceCall) -> None: """Add a custom doorbell text message.""" message: str = call.data[ATTR_MESSAGE] - await _async_service_call_nvr(hass, call, "add_custom_doorbell_message", message) + await _async_service_call_nvr(call, "add_custom_doorbell_message", message) -async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: +async def remove_doorbell_text(call: ServiceCall) -> None: """Remove a custom doorbell text message.""" message: str = call.data[ATTR_MESSAGE] - await _async_service_call_nvr(hass, call, "remove_custom_doorbell_message", message) + await _async_service_call_nvr(call, "remove_custom_doorbell_message", message) -async def remove_privacy_zone(hass: HomeAssistant, call: ServiceCall) -> None: +async def remove_privacy_zone(call: ServiceCall) -> None: """Remove privacy zone from camera.""" name: str = call.data[ATTR_NAME] - camera = _async_get_ufp_camera(hass, call) + camera = _async_get_ufp_camera(call) remove_index: int | None = None for index, zone in enumerate(camera.privacy_zones): @@ -171,10 +167,10 @@ def _async_unique_id_to_mac(unique_id: str) -> str: return unique_id.split("_")[0] -async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> None: +async def set_chime_paired_doorbells(call: ServiceCall) -> None: """Set paired doorbells on chime.""" - ref = async_extract_referenced_entity_ids(hass, call) - entity_registry = er.async_get(hass) + ref = async_extract_referenced_entity_ids(call.hass, call) + entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() chime_button = entity_registry.async_get(entity_id) @@ -182,13 +178,13 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> assert chime_button.device_id is not None chime_mac = _async_unique_id_to_mac(chime_button.unique_id) - instance = _async_get_ufp_instance(hass, chime_button.device_id) + instance = _async_get_ufp_instance(call.hass, chime_button.device_id) chime = instance.bootstrap.get_device_from_mac(chime_mac) chime = cast(Chime, chime) assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) - doorbell_refs = async_extract_referenced_entity_ids(hass, call) + doorbell_refs = async_extract_referenced_entity_ids(call.hass, call) doorbell_ids: set[str] = set() for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced: doorbell_sensor = entity_registry.async_get(camera_id) @@ -209,31 +205,32 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> await chime.save_device(data_before_changed) +SERVICES = [ + ( + SERVICE_ADD_DOORBELL_TEXT, + add_doorbell_text, + DOORBELL_TEXT_SCHEMA, + ), + ( + SERVICE_REMOVE_DOORBELL_TEXT, + remove_doorbell_text, + DOORBELL_TEXT_SCHEMA, + ), + ( + SERVICE_SET_CHIME_PAIRED, + set_chime_paired_doorbells, + CHIME_PAIRED_SCHEMA, + ), + ( + SERVICE_REMOVE_PRIVACY_ZONE, + remove_privacy_zone, + REMOVE_PRIVACY_ZONE_SCHEMA, + ), +] + + def async_setup_services(hass: HomeAssistant) -> None: """Set up the global UniFi Protect services.""" - services = [ - ( - SERVICE_ADD_DOORBELL_TEXT, - functools.partial(add_doorbell_text, hass), - DOORBELL_TEXT_SCHEMA, - ), - ( - SERVICE_REMOVE_DOORBELL_TEXT, - functools.partial(remove_doorbell_text, hass), - DOORBELL_TEXT_SCHEMA, - ), - ( - SERVICE_SET_CHIME_PAIRED, - functools.partial(set_chime_paired_doorbells, hass), - CHIME_PAIRED_SCHEMA, - ), - ( - SERVICE_REMOVE_PRIVACY_ZONE, - functools.partial(remove_privacy_zone, hass), - REMOVE_PRIVACY_ZONE_SCHEMA, - ), - ] - for name, method, schema in services: - if hass.services.has_service(DOMAIN, name): - continue + + for name, method, schema in SERVICES: hass.services.async_register(DOMAIN, name, method, schema=schema) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 6b49c859771..1e61747b3f1 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.8"] + "requirements": ["upb-lib==0.5.9"] } diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 6f0b56b14e8..8ef9f44237f 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -136,7 +136,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If version is specified, but not supported by the entity. if ( version is not None - and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features_compat + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features ): raise HomeAssistantError( f"Installing a specific version is not supported for {entity.entity_id}" @@ -145,7 +145,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] - ) and UpdateEntityFeature.BACKUP not in entity.supported_features_compat: + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") # Update is already in progress. @@ -279,7 +279,7 @@ class UpdateEntity( return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if UpdateEntityFeature.INSTALL in self.supported_features_compat: + if UpdateEntityFeature.INSTALL in self.supported_features: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -337,19 +337,6 @@ class UpdateEntity( """ return self._attr_title - @property - def supported_features_compat(self) -> UpdateEntityFeature: - """Return the supported features as UpdateEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = UpdateEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def update_percentage(self) -> int | float | None: """Update installation progress. @@ -451,7 +438,7 @@ class UpdateEntity( # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if UpdateEntityFeature.PROGRESS in self.supported_features_compat: + if UpdateEntityFeature.PROGRESS in self.supported_features: in_progress = self.in_progress update_percentage = self.update_percentage if in_progress else None if type(in_progress) is not bool and isinstance(in_progress, int): @@ -494,7 +481,7 @@ class UpdateEntity( Handles setting the in_progress state in case the entity doesn't support it natively. """ - if UpdateEntityFeature.PROGRESS not in self.supported_features_compat: + if UpdateEntityFeature.PROGRESS not in self.supported_features: self.__in_progress = True self.async_write_ha_state() @@ -539,7 +526,7 @@ async def websocket_release_notes( ) return - if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features: connection.send_error( msg["id"], websocket_api.ERR_NOT_SUPPORTED, diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index eb6db257bb2..5194965cf69 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -56,7 +56,7 @@ "services": { "install": { "name": "Install update", - "description": "Installs an update for this device or service.", + "description": "Installs an update for a device or service.", "fields": { "version": { "name": "Version", @@ -64,7 +64,7 @@ }, "backup": { "name": "Backup", - "description": "If supported by the integration, this creates a backup before starting the update ." + "description": "If supported by the integration, this creates a backup before starting the update." } } }, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c6a8635f831..aac31e085a0 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -1,9 +1,9 @@ """Support for tracking consumption over given periods of time.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging -from croniter import croniter +from cronsim import CronSim, CronSimError import voluptuous as vol from homeassistant.components.select import DOMAIN as SELECT_DOMAIN @@ -47,9 +47,12 @@ DEFAULT_OFFSET = timedelta(hours=0) def validate_cron_pattern(pattern): """Check that the pattern is well-formed.""" - if croniter.is_valid(pattern): - return pattern - raise vol.Invalid("Invalid pattern") + try: + CronSim(pattern, datetime(2020, 1, 1)) # any date will do + except CronSimError as err: + _LOGGER.error("Invalid cron pattern %s: %s", pattern, err) + raise vol.Invalid("Invalid pattern") from err + return pattern def period_or_cron(config): diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 31a2d4e9584..5167c51469d 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/utility_meter", "integration_type": "helper", "iot_class": "local_push", - "loggers": ["croniter"], "quality_scale": "internal", "requirements": ["cronsim==2.6"] } diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index a81dbeacee1..46e35bb3e11 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations +import asyncio from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any, final from propcache import cached_property import voluptuous as vol @@ -18,11 +19,9 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_IDLE, STATE_ON, - STATE_PAUSED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, @@ -32,12 +31,21 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING +from .const import ( # noqa: F401 + _DEPRECATED_STATE_CLEANING, + _DEPRECATED_STATE_DOCKED, + _DEPRECATED_STATE_ERROR, + _DEPRECATED_STATE_RETURNING, + DOMAIN, + VacuumActivity, +) _LOGGER = logging.getLogger(__name__) @@ -64,11 +72,13 @@ SERVICE_START = "start" SERVICE_PAUSE = "pause" SERVICE_STOP = "stop" - -STATES = [STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR] - DEFAULT_NAME = "Vacuum cleaner robot" +# These STATE_* constants are deprecated as of Home Assistant 2025.1. +# Please use the VacuumActivity enum instead. +_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") +_DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") + class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" @@ -216,7 +226,7 @@ STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { "battery_icon", "fan_speed", "fan_speed_list", - "state", + "activity", } @@ -233,9 +243,58 @@ class StateVacuumEntity( _attr_battery_level: int | None = None _attr_fan_speed: str | None = None _attr_fan_speed_list: list[str] - _attr_state: str | None = None + _attr_activity: VacuumActivity | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + __vacuum_legacy_state: bool = False + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + if any(method in cls.__dict__ for method in ("_attr_state", "state")): + # Integrations should use the 'activity' property instead of + # setting the state directly. + cls.__vacuum_legacy_state = True + + def __setattr__(self, name: str, value: Any) -> None: + """Set attribute. + + Deprecation warning if setting '_attr_state' directly + unless already reported. + """ + if name == "_attr_state": + self._report_deprecated_activity_handling() + return super().__setattr__(name, value) + + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + if self.__vacuum_legacy_state: + self._report_deprecated_activity_handling() + + @callback + def _report_deprecated_activity_handling(self) -> None: + """Report on deprecated handling of vacuum state. + + Integrations should implement activity instead of using state directly. + """ + report_usage( + "is setting state directly." + f" Entity {self.entity_id} ({type(self)}) should implement the 'activity'" + " property and return its state using the VacuumActivity enum", + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.1", + integration_domain=self.platform.platform_name if self.platform else None, + exclude_integrations={DOMAIN}, + ) + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -244,7 +303,7 @@ class StateVacuumEntity( @property def battery_icon(self) -> str: """Return the battery icon for the vacuum cleaner.""" - charging = bool(self.state == STATE_DOCKED) + charging = bool(self.activity == VacuumActivity.DOCKED) return icon_for_battery_level( battery_level=self.battery_level, charging=charging @@ -253,7 +312,7 @@ class StateVacuumEntity( @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + if VacuumEntityFeature.FAN_SPEED in self.supported_features: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -271,7 +330,7 @@ class StateVacuumEntity( def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -282,29 +341,34 @@ class StateVacuumEntity( return data - @cached_property + @final + @property def state(self) -> str | None: """Return the state of the vacuum cleaner.""" - return self._attr_state + if (activity := self.activity) is not None: + return activity + if self._attr_state is not None: + # Backwards compatibility for integrations that set state directly + # Should be removed in 2026.1 + if TYPE_CHECKING: + assert isinstance(self._attr_state, str) + return self._attr_state + return None + + @cached_property + def activity(self) -> VacuumActivity | None: + """Return the current vacuum activity. + + Integrations should overwrite this or use the '_attr_activity' + attribute to set the vacuum activity using the 'VacuumActivity' enum. + """ + return self._attr_activity @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> VacuumEntityFeature: - """Return the supported features as VacuumEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = VacuumEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index af1558f8570..f153a11dcb9 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -1,10 +1,42 @@ """Support for vacuum cleaner robots (botvacs).""" +from __future__ import annotations + +from enum import StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN = "vacuum" -STATE_CLEANING = "cleaning" -STATE_DOCKED = "docked" -STATE_RETURNING = "returning" -STATE_ERROR = "error" -STATES = [STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR] +class VacuumActivity(StrEnum): + """Vacuum activity states.""" + + CLEANING = "cleaning" + DOCKED = "docked" + IDLE = "idle" + PAUSED = "paused" + RETURNING = "returning" + ERROR = "error" + + +# These STATE_* constants are deprecated as of Home Assistant 2025.1. +# Please use the VacuumActivity enum instead. +_DEPRECATED_STATE_CLEANING = DeprecatedConstantEnum(VacuumActivity.CLEANING, "2026.1") +_DEPRECATED_STATE_DOCKED = DeprecatedConstantEnum(VacuumActivity.DOCKED, "2026.1") +_DEPRECATED_STATE_RETURNING = DeprecatedConstantEnum(VacuumActivity.RETURNING, "2026.1") +_DEPRECATED_STATE_ERROR = DeprecatedConstantEnum(VacuumActivity.ERROR, "2026.1") + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index f528b0918a1..4da64484bf7 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -20,7 +20,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_RETURNING +from . import DOMAIN, VacuumActivity CONDITION_TYPES = {"is_cleaning", "is_docked"} @@ -62,9 +62,9 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" if config[CONF_TYPE] == "is_docked": - test_states = [STATE_DOCKED] + test_states = [VacuumActivity.DOCKED] else: - test_states = [STATE_CLEANING, STATE_RETURNING] + test_states = [VacuumActivity.CLEANING, VacuumActivity.RETURNING] registry = er.async_get(hass) entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 45b0696f871..fe682ef21d3 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, STATE_CLEANING, STATE_DOCKED +from . import DOMAIN, VacuumActivity TRIGGER_TYPES = {"cleaning", "docked"} @@ -77,9 +77,9 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "cleaning": - to_state = STATE_CLEANING + to_state = VacuumActivity.CLEANING else: - to_state = STATE_DOCKED + to_state = VacuumActivity.DOCKED state_config = { CONF_PLATFORM: "state", diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py index 8952c13875d..48340252b6e 100644 --- a/homeassistant/components/vacuum/intent.py +++ b/homeassistant/components/vacuum/intent.py @@ -18,6 +18,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_START, description="Starts a vacuum", + required_domains={DOMAIN}, platforms={DOMAIN}, ), ) @@ -28,6 +29,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_RETURN_TO_BASE, description="Returns a vacuum to base", + required_domains={DOMAIN}, platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/vacuum/reproduce_state.py b/homeassistant/components/vacuum/reproduce_state.py index 762cd6f2e90..ef3fb329686 100644 --- a/homeassistant/components/vacuum/reproduce_state.py +++ b/homeassistant/components/vacuum/reproduce_state.py @@ -11,10 +11,8 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_IDLE, STATE_OFF, STATE_ON, - STATE_PAUSED, ) from homeassistant.core import Context, HomeAssistant, State @@ -26,20 +24,18 @@ from . import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, + VacuumActivity, ) _LOGGER = logging.getLogger(__name__) VALID_STATES_TOGGLE = {STATE_ON, STATE_OFF} VALID_STATES_STATE = { - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity.CLEANING, + VacuumActivity.DOCKED, + VacuumActivity.IDLE, + VacuumActivity.PAUSED, + VacuumActivity.RETURNING, } @@ -75,13 +71,13 @@ async def _async_reproduce_state( service = SERVICE_TURN_ON elif state.state == STATE_OFF: service = SERVICE_TURN_OFF - elif state.state == STATE_CLEANING: + elif state.state == VacuumActivity.CLEANING: service = SERVICE_START - elif state.state in [STATE_DOCKED, STATE_RETURNING]: + elif state.state in [VacuumActivity.DOCKED, VacuumActivity.RETURNING]: service = SERVICE_RETURN_TO_BASE - elif state.state == STATE_IDLE: + elif state.state == VacuumActivity.IDLE: service = SERVICE_STOP - elif state.state == STATE_PAUSED: + elif state.state == VacuumActivity.PAUSED: service = SERVICE_PAUSE await hass.services.async_call( diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 5fac46177cb..3a21ef060a7 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -83,7 +83,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index ca8cfb0f2a7..ad1c35a124b 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -2,30 +2,25 @@ from __future__ import annotations -from contextlib import suppress +import asyncio +from dataclasses import dataclass import logging import os import shutil from velbusaio.controller import Velbus -import voluptuous as vol +from velbusaio.exceptions import VelbusConnectionFailed from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import PlatformNotReady +from homeassistant.const import CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_INTERFACE, - CONF_MEMO_TEXT, - DOMAIN, - SERVICE_CLEAR_CACHE, - SERVICE_SCAN, - SERVICE_SET_MEMO_TEXT, - SERVICE_SYNC, -) +from .const import DOMAIN +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -40,13 +35,25 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def velbus_connect_task( +type VelbusConfigEntry = ConfigEntry[VelbusData] + + +@dataclass +class VelbusData: + """Runtime data for the Velbus config entry.""" + + controller: Velbus + scan_task: asyncio.Task + + +async def velbus_scan_task( controller: Velbus, hass: HomeAssistant, entry_id: str ) -> None: - """Task to offload the long running connect.""" + """Task to offload the long running scan.""" try: - await controller.connect() + await controller.start() except ConnectionError as ex: raise PlatformNotReady( f"Connection error while connecting to Velbus {entry_id}: {ex}" @@ -67,133 +74,41 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: dev_reg.async_update_device(device.id, new_identifiers=new_identifier) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Establish connection with velbus.""" - hass.data.setdefault(DOMAIN, {}) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the actions for the Velbus component.""" + setup_services(hass) + return True + +async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool: + """Establish connection with velbus.""" controller = Velbus( entry.data[CONF_PORT], cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), ) - hass.data[DOMAIN][entry.entry_id] = {} - hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller - hass.data[DOMAIN][entry.entry_id]["tsk"] = hass.async_create_task( - velbus_connect_task(controller, hass, entry.entry_id) - ) + try: + await controller.connect() + except VelbusConnectionFailed as error: + raise ConfigEntryNotReady("Cannot connect to Velbus") from error + + task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id)) + entry.runtime_data = VelbusData(controller=controller, scan_task=task) _migrate_device_identifiers(hass, entry.entry_id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - if hass.services.has_service(DOMAIN, SERVICE_SCAN): - return True - - def check_entry_id(interface: str) -> str: - for config_entry in hass.config_entries.async_entries(DOMAIN): - if "port" in config_entry.data and config_entry.data["port"] == interface: - return config_entry.entry_id - raise vol.Invalid( - "The interface provided is not defined as a port in a Velbus integration" - ) - - async def scan(call: ServiceCall) -> None: - await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].scan() - - hass.services.async_register( - DOMAIN, - SERVICE_SCAN, - scan, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), - ) - - async def syn_clock(call: ServiceCall) -> None: - await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].sync_clock() - - hass.services.async_register( - DOMAIN, - SERVICE_SYNC, - syn_clock, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), - ) - - async def set_memo_text(call: ServiceCall) -> None: - """Handle Memo Text service call.""" - memo_text = call.data[CONF_MEMO_TEXT] - await ( - hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"] - .get_module(call.data[CONF_ADDRESS]) - .set_memo_text(memo_text) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_MEMO_TEXT, - set_memo_text, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.string, - } - ), - ) - - async def clear_cache(call: ServiceCall) -> None: - """Handle a clear cache service call.""" - # clear the cache - with suppress(FileNotFoundError): - if call.data.get(CONF_ADDRESS): - await hass.async_add_executor_job( - os.unlink, - hass.config.path( - STORAGE_DIR, - f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", - ), - ) - else: - await hass.async_add_executor_job( - shutil.rmtree, - hass.config.path( - STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" - ), - ) - # call a scan to repopulate - await scan(call) - - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_CACHE, - clear_cache, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } - ), - ) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool: """Unload (close) the velbus connection.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - hass.services.async_remove(DOMAIN, SERVICE_SCAN) - hass.services.async_remove(DOMAIN, SERVICE_SYNC) - hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT) - hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE) + await entry.runtime_data.controller.stop() return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> None: """Remove the velbus entry, so we also have to cleanup the cache dir.""" await hass.async_add_executor_job( shutil.rmtree, @@ -201,7 +116,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: VelbusConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/") diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 5f363c1a035..88dc994efe8 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -3,24 +3,25 @@ from velbusaio.channels import Button as VelbusButton from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + await entry.runtime_data.scan_task async_add_entities( - VelbusBinarySensor(channel) for channel in cntrl.get_all("binary_sensor") + VelbusBinarySensor(channel) + for channel in entry.runtime_data.controller.get_all_binary_sensor() ) diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index bd5b81d67a0..fc943159123 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -8,24 +8,27 @@ from velbusaio.channels import ( ) from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusButton(channel) for channel in cntrl.get_all("button")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusButton(channel) + for channel in entry.runtime_data.controller.get_all_button() + ) class VelbusButton(VelbusEntity, ButtonEntity): diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index ed47d8b0a91..b2f3077ecee 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -11,25 +11,29 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import VelbusConfigEntry from .const import DOMAIN, PRESET_MODES from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusClimate(channel) for channel in cntrl.get_all("climate")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusClimate(channel) + for channel in entry.runtime_data.controller.get_all_climate() + ) class VelbusClimate(VelbusEntity, ClimateEntity): @@ -42,7 +46,6 @@ class VelbusClimate(VelbusEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_preset_modes = list(PRESET_MODES) - _enable_turn_on_off_backwards_compatibility = False @property def target_temperature(self) -> float | None: diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 0b47dfe6498..26e2fafabbc 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -35,7 +35,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): """Try to connect to the velbus with the port specified.""" try: controller = velbusaio.controller.Velbus(prt) - await controller.connect(True) + await controller.connect() await controller.stop() except VelbusConnectionFailed: self._errors[CONF_PORT] = "cannot_connect" diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 8b9d927f3d7..2ddea37f2d6 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -11,23 +11,26 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusCover(channel) for channel in cntrl.get_all("cover")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusCover(channel) + for channel in entry.runtime_data.controller.get_all_cover() + ) class VelbusCover(VelbusEntity, CoverEntity): diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py index f7e29e2f57e..75b7669edec 100644 --- a/homeassistant/components/velbus/diagnostics.py +++ b/homeassistant/components/velbus/diagnostics.py @@ -7,18 +7,17 @@ from typing import Any from velbusaio.channels import Channel as VelbusChannel from velbusaio.module import Module as VelbusModule -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN +from . import VelbusConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: VelbusConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - controller = hass.data[DOMAIN][entry.entry_id]["cntrl"] + controller = entry.runtime_data.controller data: dict[str, Any] = {"entry": entry.as_dict(), "modules": []} for module in controller.get_modules().values(): data["modules"].append(_build_module_diagnostics_info(module)) @@ -26,10 +25,10 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: VelbusConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" - controller = hass.data[DOMAIN][entry.entry_id]["cntrl"] + controller = entry.runtime_data.controller channel = list(next(iter(device.identifiers)))[1] modules = controller.get_modules() return _build_module_diagnostics_info(modules[int(channel)]) diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 7145576be6a..1adf52a8198 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -20,28 +20,32 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + await entry.runtime_data.scan_task entities: list[Entity] = [ - VelbusLight(channel) for channel in cntrl.get_all("light") + VelbusLight(channel) + for channel in entry.runtime_data.controller.get_all_light() ] - entities.extend(VelbusButtonLight(channel) for channel in cntrl.get_all("led")) + entities.extend( + VelbusButtonLight(channel) + for channel in entry.runtime_data.controller.get_all_led() + ) async_add_entities(entities) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 84262ebd61c..90981c426f9 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.11.1"], + "requirements": ["velbus-aio==2024.12.2"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml new file mode 100644 index 00000000000..477b6768e71 --- /dev/null +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -0,0 +1,79 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: todo + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: todo + comment: | + Manual step does not generate an unique-id + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + Dynamic devices are discovered, but no entities are created for them + entity-category: done + entity-device-class: todo + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration communicates via serial/usb/tcp and does not require a web session. + strict-typing: done diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index 7eecb85fc47..6c2dfe0a3b1 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -3,24 +3,27 @@ from velbusaio.channels import SelectedProgram from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus select based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusSelect(channel) + for channel in entry.runtime_data.controller.get_all_select() + ) class VelbusSelect(VelbusEntity, SelectEntity): diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index b765eebcddc..77833da3ee1 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -9,24 +9,24 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + await entry.runtime_data.scan_task entities = [] - for channel in cntrl.get_all("sensor"): + for channel in entry.runtime_data.controller.get_all_sensor(): entities.append(VelbusSensor(channel)) if channel.is_counter_channel(): entities.append(VelbusSensor(channel, True)) diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py new file mode 100644 index 00000000000..3f0b1bd6cdb --- /dev/null +++ b/homeassistant/components/velbus/services.py @@ -0,0 +1,132 @@ +"""Support for Velbus devices.""" + +from __future__ import annotations + +from contextlib import suppress +import os +import shutil +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.storage import STORAGE_DIR + +if TYPE_CHECKING: + from . import VelbusConfigEntry + +from .const import ( + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the velbus services.""" + + def check_entry_id(interface: str) -> str: + for config_entry in hass.config_entries.async_entries(DOMAIN): + if "port" in config_entry.data and config_entry.data["port"] == interface: + return config_entry.entry_id + raise vol.Invalid( + "The interface provided is not defined as a port in a Velbus integration" + ) + + def get_config_entry(interface: str) -> VelbusConfigEntry | None: + for config_entry in hass.config_entries.async_entries(DOMAIN): + if "port" in config_entry.data and config_entry.data["port"] == interface: + return config_entry + return None + + async def scan(call: ServiceCall) -> None: + """Handle a scan service call.""" + entry = get_config_entry(call.data[CONF_INTERFACE]) + if entry: + await entry.runtime_data.controller.scan() + + async def syn_clock(call: ServiceCall) -> None: + """Handle a sync clock service call.""" + entry = get_config_entry(call.data[CONF_INTERFACE]) + if entry: + await entry.runtime_data.controller.sync_clock() + + async def set_memo_text(call: ServiceCall) -> None: + """Handle Memo Text service call.""" + entry = get_config_entry(call.data[CONF_INTERFACE]) + if entry: + memo_text = call.data[CONF_MEMO_TEXT] + module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + if module: + await module.set_memo_text(memo_text.async_render()) + + async def clear_cache(call: ServiceCall) -> None: + """Handle a clear cache service call.""" + # clear the cache + with suppress(FileNotFoundError): + if call.data.get(CONF_ADDRESS): + await hass.async_add_executor_job( + os.unlink, + hass.config.path( + STORAGE_DIR, + f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + ), + ) + else: + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path( + STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" + ), + ) + # call a scan to repopulate + await scan(call) + + hass.services.async_register( + DOMAIN, + SERVICE_SCAN, + scan, + vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SYNC, + syn_clock, + vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + set_memo_text, + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_CACHE, + clear_cache, + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + ) diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 1e6014b8d90..8256e716d4f 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -5,23 +5,26 @@ from typing import Any from velbusaio.channels import Relay as VelbusRelay from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusSwitch(channel) for channel in cntrl.get_all("switch")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusSwitch(channel) + for channel in entry.runtime_data.controller.get_all_switch() + ) class VelbusSwitch(VelbusEntity, SwitchEntity): diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index 2865d64201e..c5323e1e9a8 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -110,7 +110,6 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO] _attr_precision = PRECISION_HALVES _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 01fe26be6bc..eb2a5206f30 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -54,7 +54,6 @@ class VeraThermostat(VeraEntity[veraApi.VeraThermostat], ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, vera_device: veraApi.VeraThermostat, controller_data: ControllerData diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 098a17e90f0..5be6a06e1d0 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -95,7 +95,6 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): ) _attr_name = None _attr_translation_key = "vesync" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, fan) -> None: """Initialize the VeSync fan device.""" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 6e449f63394..5b08b92f75a 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, ) @@ -13,11 +13,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) +MAX_MIREDS = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds +MIN_MIREDS = 153 # 1,000,000 divided by 6500 Kelvin = 153 Mireds async def async_setup_entry( @@ -84,15 +87,16 @@ class VeSyncBaseLight(VeSyncDevice, LightEntity): """Turn the device on.""" attribute_adjustment_only = False # set white temperature - if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP in kwargs: + if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN in kwargs: # get white temperature from HA data - color_temp = int(kwargs[ATTR_COLOR_TEMP]) + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) # ensure value between min-max supported Mireds - color_temp = max(self.min_mireds, min(color_temp, self.max_mireds)) + color_temp = max(MIN_MIREDS, min(color_temp, MAX_MIREDS)) # convert Mireds to Percent value that api expects color_temp = round( - ((color_temp - self.min_mireds) / (self.max_mireds - self.min_mireds)) - * 100 + ((color_temp - MIN_MIREDS) / (MAX_MIREDS - MIN_MIREDS)) * 100 ) # flip cold/warm to what pyvesync api expects color_temp = 100 - color_temp @@ -138,13 +142,13 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity): """Representation of a VeSync Tunable White Light device.""" _attr_color_mode = ColorMode.COLOR_TEMP - _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds - _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds _attr_supported_color_modes = {ColorMode.COLOR_TEMP} @property - def color_temp(self) -> int: - """Get device white temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" # get value from pyvesync library api, result = self.device.color_temp_pct try: @@ -159,15 +163,16 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity): ), result, ) - return 0 + return None # flip cold/warm color_temp_value = 100 - color_temp_value # ensure value between 0-100 color_temp_value = max(0, min(color_temp_value, 100)) # convert percent value to Mireds color_temp_value = round( - self.min_mireds - + ((self.max_mireds - self.min_mireds) / 100 * color_temp_value) + MIN_MIREDS + ((MAX_MIREDS - MIN_MIREDS) / 100 * color_temp_value) ) # ensure value between minimum and maximum Mireds - return max(self.min_mireds, min(color_temp_value, self.max_mireds)) + return color_util.color_temperature_mired_to_kelvin( + max(MIN_MIREDS, min(color_temp_value, MAX_MIREDS)) + ) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 8a116038533..67330bf201d 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -140,7 +140,6 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _current_action: bool | None = None _current_mode: str | None = None _current_program: str | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 1800704a16f..6e8513a1f7e 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -125,7 +125,6 @@ class ViCareFan(ViCareEntity, FanEntity): _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 8ce996ab81d..0bb5594e829 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.35.0"] + "requirements": ["PyViCare==2.38.0"] } diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 09d6f3be090..4af42d76b62 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -4,55 +4,18 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_DEVICE_CLASS, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import ConfigType -from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA +from .const import CONF_APPS, DOMAIN from .coordinator import VizioAppsDataUpdateCoordinator - -def validate_apps(config: ConfigType) -> ConfigType: - """Validate CONF_APPS is only used when CONF_DEVICE_CLASS is MediaPlayerDeviceClass.TV.""" - if ( - config.get(CONF_APPS) is not None - and config[CONF_DEVICE_CLASS] != MediaPlayerDeviceClass.TV - ): - raise vol.Invalid( - f"'{CONF_APPS}' can only be used if {CONF_DEVICE_CLASS}' is" - f" '{MediaPlayerDeviceClass.TV}'" - ) - - return config - - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [vol.All(VIZIO_SCHEMA, validate_apps)])}, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Component setup, run import config flow for each entry in config.""" - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load the saved entities.""" diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 49f6a709565..d3921061d8e 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -14,8 +14,6 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigEntry, ConfigFlow, @@ -251,98 +249,13 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: return await self._create_entry(user_input) - elif self._must_show_form and self.context["source"] == SOURCE_IMPORT: - # Import should always display the config form if CONF_ACCESS_TOKEN - # wasn't included but is needed so that the user can choose to update - # their configuration.yaml or to proceed with config flow pairing. We - # will also provide contextual message to user explaining why - _LOGGER.warning( - ( - "Couldn't complete configuration.yaml import: '%s' key is " - "missing. Either provide '%s' key in configuration.yaml or " - "finish setup by completing configuration via frontend" - ), - CONF_ACCESS_TOKEN, - CONF_ACCESS_TOKEN, - ) - self._must_show_form = False else: self._data = copy.deepcopy(user_input) return await self.async_step_pair_tv() schema = self._user_schema or _get_config_schema() - - if errors and self.context["source"] == SOURCE_IMPORT: - # Log an error message if import config flow fails since otherwise failure is silent - _LOGGER.error( - "Importing from configuration.yaml failed: %s", - ", ".join(errors.values()), - ) - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry from configuration.yaml.""" - # Check if new config entry matches any existing config entries - for entry in self._async_current_entries(): - # If source is ignore bypass host check and continue through loop - if entry.source == SOURCE_IGNORE: - continue - - if await self.hass.async_add_executor_job( - _host_is_same, entry.data[CONF_HOST], import_data[CONF_HOST] - ): - updated_options: dict[str, Any] = {} - updated_data: dict[str, Any] = {} - remove_apps = False - - if entry.data[CONF_HOST] != import_data[CONF_HOST]: - updated_data[CONF_HOST] = import_data[CONF_HOST] - - if entry.data[CONF_NAME] != import_data[CONF_NAME]: - updated_data[CONF_NAME] = import_data[CONF_NAME] - - # Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and - # pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified - if entry.data.get(CONF_APPS) != import_data.get(CONF_APPS): - if not import_data.get(CONF_APPS): - remove_apps = True - else: - updated_options[CONF_APPS] = import_data[CONF_APPS] - - if entry.data.get(CONF_VOLUME_STEP) != import_data[CONF_VOLUME_STEP]: - updated_options[CONF_VOLUME_STEP] = import_data[CONF_VOLUME_STEP] - - if updated_options or updated_data or remove_apps: - new_data = entry.data.copy() - new_options = entry.options.copy() - - if remove_apps: - new_data.pop(CONF_APPS) - new_options.pop(CONF_APPS) - - if updated_data: - new_data.update(updated_data) - - # options are stored in entry options and data so update both - if updated_options: - new_data.update(updated_options) - new_options.update(updated_options) - - self.hass.config_entries.async_update_entry( - entry=entry, data=new_data, options=new_options - ) - return self.async_abort(reason="updated_entry") - - return self.async_abort(reason="already_configured_device") - - self._must_show_form = True - # Store config key/value pairs that are not configurable in user step so they - # don't get lost on user step - if import_data.get(CONF_APPS): - self._apps = copy.deepcopy(import_data[CONF_APPS]) - return await self.async_step_user(user_input=import_data) - async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -433,11 +346,6 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): if pair_data: self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token self._must_show_form = True - - if self.context["source"] == SOURCE_IMPORT: - # If user is pairing via config import, show different message - return await self.async_step_pairing_complete_import() - return await self.async_step_pairing_complete() # If no data was retrieved, it's assumed that the pairing attempt was not diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 4eb96256d2e..8451ae747de 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -10,14 +10,6 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_DEVICE_CLASS, - CONF_EXCLUDE, - CONF_HOST, - CONF_INCLUDE, - CONF_NAME, -) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import VolDictType @@ -84,43 +76,3 @@ VIZIO_DEVICE_CLASSES = { MediaPlayerDeviceClass.SPEAKER: VIZIO_DEVICE_CLASS_SPEAKER, MediaPlayerDeviceClass.TV: VIZIO_DEVICE_CLASS_TV, } - -VIZIO_SCHEMA = { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.All( - cv.string, - vol.Lower, - vol.In([MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.SPEAKER]), - ), - vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All( - vol.Coerce(int), vol.Range(min=1, max=10) - ), - vol.Optional(CONF_APPS): vol.All( - { - vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONFIG): { - vol.Required(CONF_APP_ID): cv.string, - vol.Required(CONF_NAME_SPACE): vol.Coerce(int), - vol.Optional(CONF_MESSAGE, default=None): vol.Or( - cv.string, None - ), - }, - }, - ], - ), - }, - cv.has_at_least_one_key(CONF_INCLUDE, CONF_EXCLUDE, CONF_ADDITIONAL_CONFIGS), - ), -} diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index d2f408e355b..e95ca2b5976 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta +from json.decoder import JSONDecodeError from typing import Any from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions @@ -107,6 +108,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): exceptions.CannotConnect, exceptions.AlreadyLogged, exceptions.GenericLoginError, + JSONDecodeError, ) as err: raise UpdateFailed(f"Error fetching data: {err!r}") from err except (ConfigEntryAuthFailed, UpdateFailed): diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index dbd697f2367..cac0a365f74 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -56,7 +56,7 @@ STATE_GAS = "gas" class WaterHeaterEntityFeature(IntFlag): - """Supported features of the fan entity.""" + """Supported features of the water heater entity.""" TARGET_TEMPERATURE = 1 OPERATION_MODE = 2 @@ -129,7 +129,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): +class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" @@ -152,7 +152,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} ) - entity_description: WaterHeaterEntityEntityDescription + entity_description: WaterHeaterEntityDescription _attr_current_operation: str | None = None _attr_current_temperature: float | None = None _attr_is_away_mode_on: bool | None = None @@ -194,7 +194,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features_compat: + if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: data[ATTR_OPERATION_LIST] = self.operation_list return data @@ -230,7 +230,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - supported_features = self.supported_features_compat + supported_features = self.supported_features if WaterHeaterEntityFeature.OPERATION_MODE in supported_features: data[ATTR_OPERATION_MODE] = self.current_operation @@ -379,19 +379,6 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> WaterHeaterEntityFeature: - """Return the supported features as WaterHeaterEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = WaterHeaterEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - async def async_service_away_mode( entity: WaterHeaterEntity, service: ServiceCall diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py new file mode 100644 index 00000000000..1cf38876556 --- /dev/null +++ b/homeassistant/components/watergate/__init__.py @@ -0,0 +1,107 @@ +"""The Watergate integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import logging + +from watergate_local_api import WatergateLocalApiClient +from watergate_local_api.models import WebhookEvent + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + Request, + Response, + async_generate_url, + async_register, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import WatergateDataCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [ + Platform.VALVE, +] + +type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: + """Set up Watergate from a config entry.""" + sonic_address = entry.data[CONF_IP_ADDRESS] + webhook_id = entry.data[CONF_WEBHOOK_ID] + + _LOGGER.debug( + "Setting up watergate local api integration for device: IP: %s)", + sonic_address, + ) + + watergate_client = WatergateLocalApiClient( + sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}" + ) + + coordinator = WatergateDataCoordinator(hass, watergate_client) + entry.runtime_data = coordinator + + async_register( + hass, DOMAIN, "Watergate", webhook_id, get_webhook_handler(coordinator) + ) + + _LOGGER.debug("Registered webhook: %s", webhook_id) + + await coordinator.async_config_entry_first_refresh() + + await watergate_client.async_set_webhook_url( + async_generate_url(hass, webhook_id, allow_ip=True, prefer_external=False) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: + """Unload a config entry.""" + webhook_id = entry.data[CONF_WEBHOOK_ID] + hass.components.webhook.async_unregister(webhook_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_webhook_handler( + coordinator: WatergateDataCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http post calls to the path. + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + + body = await request.json() + + _LOGGER.debug("Received webhook: %s", body) + + data = WebhookEvent.parse_webhook_event(body) + + body_type = body.get("type") + + coordinator_data = coordinator.data + if body_type == Platform.VALVE and coordinator_data: + coordinator_data.valve_state = data.state + + coordinator.async_set_updated_data(coordinator_data) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler diff --git a/homeassistant/components/watergate/config_flow.py b/homeassistant/components/watergate/config_flow.py new file mode 100644 index 00000000000..de8494053a3 --- /dev/null +++ b/homeassistant/components/watergate/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Watergate.""" + +import logging + +import voluptuous as vol +from watergate_local_api.watergate_api import ( + WatergateApiException, + WatergateLocalApiClient, +) + +from homeassistant.components.webhook import async_generate_id as webhook_generate_id +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SONIC = "Sonic" +WATERGATE_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + } +) + + +class WatergateConfigFlow(ConfigFlow, domain=DOMAIN): + """Watergate config flow.""" + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + watergate_client = WatergateLocalApiClient( + self.prepare_ip_address(user_input[CONF_IP_ADDRESS]) + ) + try: + state = await watergate_client.async_get_device_state() + except WatergateApiException as exception: + _LOGGER.error("Error connecting to Watergate device: %s", exception) + errors[CONF_IP_ADDRESS] = "cannot_connect" + else: + if state is None: + _LOGGER.error("Device state returned as None") + errors[CONF_IP_ADDRESS] = "cannot_connect" + else: + await self.async_set_unique_id(state.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, + title=SONIC, + ) + + return self.async_show_form( + step_id="user", data_schema=WATERGATE_SCHEMA, errors=errors + ) + + def prepare_ip_address(self, ip_address: str) -> str: + """Prepare the IP address for the Watergate device.""" + return ip_address if ip_address.startswith("http") else f"http://{ip_address}" diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py new file mode 100644 index 00000000000..22a14330af9 --- /dev/null +++ b/homeassistant/components/watergate/const.py @@ -0,0 +1,5 @@ +"""Constants for the Watergate integration.""" + +DOMAIN = "watergate" + +MANUFACTURER = "Watergate" diff --git a/homeassistant/components/watergate/coordinator.py b/homeassistant/components/watergate/coordinator.py new file mode 100644 index 00000000000..c0b87feed30 --- /dev/null +++ b/homeassistant/components/watergate/coordinator.py @@ -0,0 +1,35 @@ +"""Coordinator for Watergate API.""" + +from datetime import timedelta +import logging + +from watergate_local_api import WatergateApiException, WatergateLocalApiClient +from watergate_local_api.models import DeviceState + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class WatergateDataCoordinator(DataUpdateCoordinator[DeviceState]): + """Class to manage fetching watergate data.""" + + def __init__(self, hass: HomeAssistant, api: WatergateLocalApiClient) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=2), + ) + self.api = api + + async def _async_update_data(self) -> DeviceState: + try: + state = await self.api.async_get_device_state() + except WatergateApiException as exc: + raise UpdateFailed from exc + return state diff --git a/homeassistant/components/watergate/entity.py b/homeassistant/components/watergate/entity.py new file mode 100644 index 00000000000..977a7fbedb4 --- /dev/null +++ b/homeassistant/components/watergate/entity.py @@ -0,0 +1,30 @@ +"""Watergate Base Entity Definition.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WatergateDataCoordinator + + +class WatergateEntity(CoordinatorEntity[WatergateDataCoordinator]): + """Define a base Watergate entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WatergateDataCoordinator, + entity_name: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._api_client = coordinator.api + self._attr_unique_id = f"{coordinator.data.serial_number}.{entity_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + name="Sonic", + serial_number=coordinator.data.serial_number, + manufacturer=MANUFACTURER, + sw_version=coordinator.data.firmware_version if coordinator.data else None, + ) diff --git a/homeassistant/components/watergate/manifest.json b/homeassistant/components/watergate/manifest.json new file mode 100644 index 00000000000..46a80e15671 --- /dev/null +++ b/homeassistant/components/watergate/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "watergate", + "name": "Watergate", + "codeowners": ["@adam-the-hero"], + "config_flow": true, + "dependencies": ["http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/watergate", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["watergate-local-api==2024.4.1"] +} diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml new file mode 100644 index 00000000000..c6027f6a548 --- /dev/null +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -0,0 +1,43 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: todo diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json new file mode 100644 index 00000000000..2a75c4d103d --- /dev/null +++ b/homeassistant/components/watergate/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "title": "Configure Watergate device", + "data_description": { + "ip_address": "Provide an IP address of your Watergate device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py new file mode 100644 index 00000000000..aecaf3fbca9 --- /dev/null +++ b/homeassistant/components/watergate/valve.py @@ -0,0 +1,82 @@ +"""Support for Watergate Valve.""" + +from homeassistant.components.sensor import Any, HomeAssistant +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, + ValveState, +) +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import WatergateConfigEntry +from .coordinator import WatergateDataCoordinator +from .entity import WatergateEntity + +ENTITY_NAME = "valve" +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all entries for Watergate Platform.""" + + async_add_entities([SonicValve(config_entry.runtime_data)]) + + +class SonicValve(WatergateEntity, ValveEntity): + """Define a Sonic Valve entity.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + _valve_state: str | None = None + _attr_device_class = ValveDeviceClass.WATER + _attr_name = None + + def __init__( + self, + coordinator: WatergateDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, ENTITY_NAME) + self._valve_state = coordinator.data.valve_state if coordinator.data else None + + @property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self._valve_state == ValveState.CLOSED + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening or not.""" + return self._valve_state == ValveState.OPENING + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing or not.""" + return self._valve_state == ValveState.CLOSING + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._attr_available = self.coordinator.data is not None + self._valve_state = ( + self.coordinator.data.valve_state if self.coordinator.data else None + ) + self.async_write_ha_state() + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + await self._api_client.async_set_valve_state(ValveState.OPEN) + self._valve_state = ValveState.OPENING + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close the valve.""" + await self._api_client.async_set_valve_state(ValveState.CLOSED) + self._valve_state = ValveState.CLOSING + self.async_write_ha_state() diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index f9d3270aaa0..42dae679aa5 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -81,7 +81,6 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): ) wemo: Humidifier _last_fan_on_mode: FanMode - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo switch.""" diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 26dec417631..6068cd3ff0b 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -8,9 +8,11 @@ from pywemo import Bridge, BridgeLight, Dimmer from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, LightEntityFeature, @@ -77,6 +79,8 @@ def async_setup_bridge( class WemoLight(WemoEntity, LightEntity): """Representation of a WeMo light.""" + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN _attr_supported_features = LightEntityFeature.TRANSITION def __init__(self, coordinator: DeviceCoordinator, light: BridgeLight) -> None: @@ -123,9 +127,11 @@ class WemoLight(WemoEntity, LightEntity): return self.light.state.get("color_xy") @property - def color_temp(self) -> int | None: - """Return the color temperature of this light in mireds.""" - return self.light.state.get("temperature_mireds") + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if not (mireds := self.light.state.get("temperature_mireds")): + return None + return color_util.color_temperature_mired_to_kelvin(mireds) @property def color_mode(self) -> ColorMode: @@ -165,7 +171,7 @@ class WemoLight(WemoEntity, LightEntity): xy_color = None brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hs_color = kwargs.get(ATTR_HS_COLOR) transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) @@ -182,9 +188,9 @@ class WemoLight(WemoEntity, LightEntity): if xy_color is not None: self.light.set_color(xy_color, transition=transition_time) - if color_temp is not None: + if color_temp_kelvin is not None: self.light.set_temperature( - mireds=color_temp, transition=transition_time + kelvin=color_temp_kelvin, transition=transition_time ) self.light.turn_on(**turn_on_kwargs) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 36f8fbec59d..64adcda4742 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -20,8 +20,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Set up Whirlpool Sixth Sense from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -47,21 +49,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Cannot fetch appliances") return False - hass.data[DOMAIN][entry.entry_id] = WhirlpoolData( - appliances_manager, auth, backend_selector - ) + entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @dataclass diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index aa399746006..943c5d1c956 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -23,7 +23,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -31,7 +30,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WhirlpoolData +from . import WhirlpoolConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -70,11 +69,11 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WhirlpoolConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] + whirlpool_data = config_entry.runtime_data aircons = [ AirConEntity( @@ -110,7 +109,6 @@ class AirConEntity(ClimateEntity): _attr_swing_modes = SUPPORTED_SWING_MODES _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 9b1dd00e7bd..87d6ea827e2 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import WhirlpoolData -from .const import DOMAIN +from . import WhirlpoolConfigEntry TO_REDACT = { "SERIAL_NUMBER", @@ -24,11 +22,11 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WhirlpoolConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - whirlpool: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] + whirlpool = config_entry.runtime_data diagnostics_data = { "Washer_dryers": { wd["NAME"]: dict(wd.items()) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 8c74f01298e..b84518cedf1 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from . import WhirlpoolData +from . import WhirlpoolConfigEntry from .const import DOMAIN TANK_FILL = { @@ -132,12 +131,12 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WhirlpoolConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Config flow entry for Whrilpool Laundry.""" entities: list = [] - whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] + whirlpool_data = config_entry.runtime_data for appliance in whirlpool_data.appliances_manager.washer_dryers: _wd = WasherDryer( whirlpool_data.backend_selector, diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 71f1098603b..a14198e3b5d 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -64,7 +64,6 @@ class WiLightFan(WiLightDevice, FanEntity): | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json index 79ff7489bf8..8123337dc82 100644 --- a/homeassistant/components/withings/icons.json +++ b/homeassistant/components/withings/icons.json @@ -16,6 +16,9 @@ "heart_pulse": { "default": "mdi:heart-pulse" }, + "height": { + "default": "mdi:human-male-height-variant" + }, "hydration": { "default": "mdi:water" }, diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 57d4bafdc7b..886eb66f5e0 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.3"] + "requirements": ["aiowithings==3.1.4"] } diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index a3f36d580d2..9ef4cd57b3d 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -10,7 +10,7 @@ from pywizlight.scenes import get_id_from_scene_name from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -21,10 +21,6 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from . import WizConfigEntry from .entity import WizToggleEntity @@ -43,10 +39,10 @@ def _async_pilot_builder(**kwargs: Any) -> PilotBuilder: if ATTR_RGBW_COLOR in kwargs: return PilotBuilder(brightness=brightness, rgbw=kwargs[ATTR_RGBW_COLOR]) - if ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN in kwargs: return PilotBuilder( brightness=brightness, - colortemp=color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]), + colortemp=kwargs[ATTR_COLOR_TEMP_KELVIN], ) if ATTR_EFFECT in kwargs: @@ -93,8 +89,8 @@ class WizBulbEntity(WizToggleEntity, LightEntity): self._attr_effect_list = wiz_data.scenes if bulb_type.bulb_type != BulbClass.DW: kelvin = bulb_type.kelvin_range - self._attr_min_mireds = color_temperature_kelvin_to_mired(kelvin.max) - self._attr_max_mireds = color_temperature_kelvin_to_mired(kelvin.min) + self._attr_max_color_temp_kelvin = kelvin.max + self._attr_min_color_temp_kelvin = kelvin.min if bulb_type.features.effect: self._attr_supported_features = LightEntityFeature.EFFECT self._async_update_attrs() @@ -111,7 +107,7 @@ class WizBulbEntity(WizToggleEntity, LightEntity): color_temp := state.get_colortemp() ): self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = color_temperature_kelvin_to_mired(color_temp) + self._attr_color_temp_kelvin = color_temp elif ( ColorMode.RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None ): diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index c731f8181af..326008ae1af 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/wled", "integration_type": "device", "iot_class": "local_push", - "requirements": ["wled==0.20.2"], + "requirements": ["wled==0.21.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ea08bfe1717..842c6f1f1ad 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.61"] + "requirements": ["holidays==0.62"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index e74dc0160d9..87fa294dbba 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -14,9 +14,9 @@ "options": { "description": "Set additional options for {name} configured for country {country}", "data": { - "excludes": "Excludes", + "excludes": "Days to exclude", "days_offset": "Offset", - "workdays": "Workdays", + "workdays": "Days to include", "add_holidays": "Add holidays", "remove_holidays": "Remove Holidays", "province": "Subdivision of country", @@ -24,9 +24,9 @@ "category": "Additional category as holiday" }, "data_description": { - "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly", + "excludes": "Select which weekdays to exclude as workdays.\nThe key `holidays` adds those for the configured country, customizable by all the settings below. Read the documentation on how to use them correctly.", "days_offset": "Days offset from current day", - "workdays": "List of working days", + "workdays": "Select which weekdays to include as possible workdays.", "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "province": "State, territory, province or region of country", diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 30a6c3bc700..3fc2071e66b 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/xbox", "iot_class": "cloud_polling", - "requirements": ["xbox-webapi==2.0.11"] + "requirements": ["xbox-webapi==2.1.0"] } diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 81ca38eb053..e1de3f56252 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -300,7 +300,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Representation of a generic Xiaomi device.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 8ccc798a2e1..3f1f8b926b3 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -28,7 +28,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -45,7 +45,7 @@ from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import color, dt as dt_util +from homeassistant.util import color as color_util, dt as dt_util from .const import ( CONF_FLOW_TYPE, @@ -430,33 +430,54 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): self._color_temp = None @property - def color_temp(self): + def _current_mireds(self): """Return the color temperature.""" return self._color_temp @property - def min_mireds(self): + def _min_mireds(self): """Return the coldest color_temp that this light supports.""" return 175 @property - def max_mireds(self): + def _max_mireds(self): """Return the warmest color_temp that this light supports.""" return 333 + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return ( + color_util.color_temperature_mired_to_kelvin(self._color_temp) + if self._color_temp + else None + ) + + @property + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin(self._max_mireds) + + @property + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin(self._min_mireds) + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) percent_color_temp = self.translate( - color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX + color_temp, self._max_mireds, self._min_mireds, CCT_MIN, CCT_MAX ) if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] percent_brightness = ceil(100 * brightness / 255.0) - if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( "Setting brightness and color temperature: %s %s%%, %s mireds, %s%% cct", brightness, @@ -476,7 +497,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): self._color_temp = color_temp self._brightness = brightness - elif ATTR_COLOR_TEMP in kwargs: + elif ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( "Setting color temperature: %s mireds, %s%% cct", color_temp, @@ -526,7 +547,11 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( - state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds + state.color_temperature, + CCT_MIN, + CCT_MAX, + self._max_mireds, + self._min_mireds, ) delayed_turn_off = self.delayed_turn_off_timestamp( @@ -560,12 +585,12 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): ) @property - def min_mireds(self): + def _min_mireds(self): """Return the coldest color_temp that this light supports.""" return 175 @property - def max_mireds(self): + def _max_mireds(self): """Return the warmest color_temp that this light supports.""" return 370 @@ -585,7 +610,11 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( - state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds + state.color_temperature, + CCT_MIN, + CCT_MAX, + self._max_mireds, + self._min_mireds, ) delayed_turn_off = self.delayed_turn_off_timestamp( @@ -797,12 +826,12 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): ) @property - def min_mireds(self): + def _min_mireds(self): """Return the coldest color_temp that this light supports.""" return 153 @property - def max_mireds(self): + def _max_mireds(self): """Return the warmest color_temp that this light supports.""" return 588 @@ -820,10 +849,12 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) percent_color_temp = self.translate( - color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX + color_temp, self._max_mireds, self._min_mireds, CCT_MIN, CCT_MAX ) if ATTR_BRIGHTNESS in kwargs: @@ -832,7 +863,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): if ATTR_HS_COLOR in kwargs: hs_color = kwargs[ATTR_HS_COLOR] - rgb = color.color_hs_to_RGB(*hs_color) + rgb = color_util.color_hs_to_RGB(*hs_color) if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs: _LOGGER.debug( @@ -853,7 +884,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): self._hs_color = hs_color self._brightness = brightness - elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( ( "Setting brightness and color temperature: " @@ -886,7 +917,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): if result: self._hs_color = hs_color - elif ATTR_COLOR_TEMP in kwargs: + elif ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( "Setting color temperature: %s mireds, %s%% cct", color_temp, @@ -936,9 +967,13 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( - state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds + state.color_temperature, + CCT_MIN, + CCT_MAX, + self._max_mireds, + self._min_mireds, ) - self._hs_color = color.color_RGB_to_hs(*state.rgb) + self._hs_color = color_util.color_RGB_to_hs(*state.rgb) self._state_attrs.update( { @@ -1014,7 +1049,7 @@ class XiaomiGatewayLight(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_HS_COLOR in kwargs: - rgb = color.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) else: rgb = self._rgb @@ -1052,7 +1087,7 @@ class XiaomiGatewayLight(LightEntity): if self._is_on: self._brightness_pct = state_dict["brightness"] self._rgb = state_dict["rgb"] - self._hs = color.color_RGB_to_hs(*self._rgb) + self._hs = color_util.color_RGB_to_hs(*self._rgb) class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): @@ -1067,7 +1102,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): return round((self._sub_device.status["brightness"] * 255) / 100) @property - def color_temp(self): + def _current_mireds(self): """Return current color temperature.""" return self._sub_device.status["color_temp"] @@ -1077,12 +1112,12 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): return self._sub_device.status["status"] == "on" @property - def min_mireds(self): + def _min_mireds(self): """Return min cct.""" return self._sub_device.status["cct_min"] @property - def max_mireds(self): + def _max_mireds(self): """Return max cct.""" return self._sub_device.status["cct_max"] @@ -1090,8 +1125,10 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): """Instruct the light to turn on.""" await self.hass.async_add_executor_job(self._sub_device.on) - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) await self.hass.async_add_executor_job( self._sub_device.set_color_temp, color_temp ) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index b720cc90d2c..532eb9581cd 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -10,13 +10,8 @@ from miio import DeviceException import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -55,29 +50,29 @@ ATTR_ZONE_REPEATER = "repeats" ATTR_TIMERS = "timers" STATE_CODE_TO_STATE = { - 1: STATE_IDLE, # "Starting" - 2: STATE_IDLE, # "Charger disconnected" - 3: STATE_IDLE, # "Idle" - 4: STATE_CLEANING, # "Remote control active" - 5: STATE_CLEANING, # "Cleaning" - 6: STATE_RETURNING, # "Returning home" - 7: STATE_CLEANING, # "Manual mode" - 8: STATE_DOCKED, # "Charging" - 9: STATE_ERROR, # "Charging problem" - 10: STATE_PAUSED, # "Paused" - 11: STATE_CLEANING, # "Spot cleaning" - 12: STATE_ERROR, # "Error" - 13: STATE_IDLE, # "Shutting down" - 14: STATE_DOCKED, # "Updating" - 15: STATE_RETURNING, # "Docking" - 16: STATE_CLEANING, # "Going to target" - 17: STATE_CLEANING, # "Zoned cleaning" - 18: STATE_CLEANING, # "Segment cleaning" - 22: STATE_DOCKED, # "Emptying the bin" on s7+ - 23: STATE_DOCKED, # "Washing the mop" on s7maxV - 26: STATE_RETURNING, # "Going to wash the mop" on s7maxV - 100: STATE_DOCKED, # "Charging complete" - 101: STATE_ERROR, # "Device offline" + 1: VacuumActivity.IDLE, # "Starting" + 2: VacuumActivity.IDLE, # "Charger disconnected" + 3: VacuumActivity.IDLE, # "Idle" + 4: VacuumActivity.CLEANING, # "Remote control active" + 5: VacuumActivity.CLEANING, # "Cleaning" + 6: VacuumActivity.RETURNING, # "Returning home" + 7: VacuumActivity.CLEANING, # "Manual mode" + 8: VacuumActivity.DOCKED, # "Charging" + 9: VacuumActivity.ERROR, # "Charging problem" + 10: VacuumActivity.PAUSED, # "Paused" + 11: VacuumActivity.CLEANING, # "Spot cleaning" + 12: VacuumActivity.ERROR, # "Error" + 13: VacuumActivity.IDLE, # "Shutting down" + 14: VacuumActivity.DOCKED, # "Updating" + 15: VacuumActivity.RETURNING, # "Docking" + 16: VacuumActivity.CLEANING, # "Going to target" + 17: VacuumActivity.CLEANING, # "Zoned cleaning" + 18: VacuumActivity.CLEANING, # "Segment cleaning" + 22: VacuumActivity.DOCKED, # "Emptying the bin" on s7+ + 23: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV + 26: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV + 100: VacuumActivity.DOCKED, # "Charging complete" + 101: VacuumActivity.ERROR, # "Device offline" } @@ -211,7 +206,7 @@ class MiroboVacuum( ) -> None: """Initialize the Xiaomi vacuum cleaner robot handler.""" super().__init__(device, entry, unique_id, coordinator) - self._state: str | None = None + self._state: VacuumActivity | None = None async def async_added_to_hass(self) -> None: """Run when entity is about to be added to hass.""" @@ -219,12 +214,12 @@ class MiroboVacuum( self._handle_coordinator_update() @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" # The vacuum reverts back to an idle state after erroring out. # We want to keep returning an error until it has been cleared. if self.coordinator.data.status.got_error: - return STATE_ERROR + return VacuumActivity.ERROR return self._state diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index c7d580631d3..3bb80df25b2 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -56,7 +56,6 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device, sensor): """Initialize the actuator.""" diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 50c2a0af457..7b7edfac77b 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.5"] } diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index c543de89b84..d67e136be4a 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CODE +from homeassistant.const import CONF_CODE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -27,21 +27,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: + new_options = entry.options.copy() if config_entry_default_code := entry.options.get(CONF_CODE): entity_reg = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) @@ -52,12 +53,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOCK_DOMAIN, {CONF_DEFAULT_CODE: config_entry_default_code}, ) - new_options = entry.options.copy() del new_options[CONF_CODE] - hass.config_entries.async_update_entry(entry, options=new_options) + hass.config_entries.async_update_entry(entry, options=new_options, version=2) - hass.config_entries.async_update_entry(entry, version=2) + if entry.version == 2 and entry.minor_version == 1: + # Removes name from entry data + new_data = entry.data.copy() + del new_data[CONF_NAME] + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) LOGGER.debug("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 0f5b7d0b8e5..8244d96064a 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -15,7 +15,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,7 +46,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: """Initialize the Yale Alarm Device.""" super().__init__(coordinator) - self._attr_unique_id = coordinator.entry.entry_id + self._attr_unique_id = coordinator.config_entry.entry_id async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -84,7 +83,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): translation_domain=DOMAIN, translation_key="set_alarm", translation_placeholders={ - "name": self.coordinator.entry.data[CONF_NAME], + "name": self.coordinator.config_entry.title, "error": str(error), }, ) from error diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 8e68b1f0cb4..17b6035321a 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -108,7 +108,9 @@ class YaleProblemSensor(YaleAlarmEntity, BinarySensorEntity): """Initiate Yale Problem Sensor.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.entry.entry_id}-{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index c71b7b33a08..3ceee367284 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -23,7 +23,6 @@ from .const import ( CONF_AREA_ID, CONF_LOCK_CODE_DIGITS, DEFAULT_AREA_ID, - DEFAULT_NAME, DOMAIN, YALE_BASE_ERRORS, ) @@ -67,6 +66,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" VERSION = 2 + MINOR_VERSION = 2 @staticmethod @callback @@ -146,7 +146,6 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - name = DEFAULT_NAME area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID) errors = await self.hass.async_add_executor_job( @@ -161,7 +160,6 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_USERNAME: username, CONF_PASSWORD: password, - CONF_NAME: name, CONF_AREA_ID: area, }, ) diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 66bd71c9f1e..7ece2a3448b 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -9,12 +9,14 @@ from yalesmartalarmclient import YaleLock from yalesmartalarmclient.client import YaleSmartAlarmClient from yalesmartalarmclient.exceptions import AuthenticationError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import YaleConfigEntry + from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS @@ -22,13 +24,14 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A Yale Data Update Coordinator.""" yale: YaleSmartAlarmClient + config_entry: YaleConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: YaleConfigEntry) -> None: """Initialize the Yale hub.""" - self.entry = entry super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), always_update=False, @@ -40,8 +43,8 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: self.yale = await self.hass.async_add_executor_job( YaleSmartAlarmClient, - self.entry.data[CONF_USERNAME], - self.entry.data[CONF_PASSWORD], + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], ) self.locks = await self.hass.async_add_executor_job(self.yale.get_locks) except AuthenticationError as error: diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index e37dc3562f5..2610f54f0a9 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -2,7 +2,7 @@ from yalesmartalarmclient import YaleLock -from homeassistant.const import CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, data["address"])}, - via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), + via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), ) @@ -43,7 +43,7 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, lock.sid())}, - via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), + via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), ) self.lock_data = lock @@ -58,10 +58,10 @@ class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): super().__init__(coordinator) panel_info = coordinator.data["panel_info"] self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.entry.data[CONF_USERNAME])}, + identifiers={(DOMAIN, coordinator.config_entry.data[CONF_USERNAME])}, manufacturer=MANUFACTURER, model=MODEL, - name=coordinator.entry.data[CONF_NAME], + name=coordinator.config_entry.title, connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])}, sw_version=panel_info["version"], ) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 243299658ed..7a93baf0827 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -9,7 +9,7 @@ from yalesmartalarmclient import YaleLock, YaleLockState from homeassistant.components.lock import LockEntity, LockState from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import YaleConfigEntry @@ -65,12 +65,6 @@ class YaleDoorlock(YaleLockEntity, LockEntity): async def async_set_lock(self, state: YaleLockState, code: str | None) -> None: """Set lock.""" - if state is YaleLockState.UNLOCKED and not code: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_code", - ) - lock_state = False try: if state is YaleLockState.LOCKED: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 7f940e1139e..bd3ba0f0186 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -88,9 +88,6 @@ "set_lock": { "message": "Could not set lock for {name}: {error}" }, - "no_code": { - "message": "Can not unlock without code" - }, "could_not_change_lock": { "message": "Could not set lock, check system ready for lock" }, diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c3d1a3d97f1..b2c331397b3 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.1"] + "requirements": ["yalexs-ble==2.5.5"] } diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index d0d53510859..8cc3f2600e5 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -16,11 +16,10 @@ from yeelight.main import BulbException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, FLASH_LONG, @@ -40,10 +39,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType import homeassistant.util.color as color_util -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) from . import YEELIGHT_FLOW_TRANSITION_SCHEMA from .const import ( @@ -71,6 +66,7 @@ from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) ATTR_MINUTES = "minutes" +ATTR_KELVIN = "kelvin" SERVICE_SET_MODE = "set_mode" SERVICE_SET_MUSIC_MODE = "set_music_mode" @@ -440,8 +436,8 @@ class YeelightBaseLight(YeelightEntity, LightEntity): self._effect = None model_specs = self._bulb.get_model_specs() - self._attr_min_mireds = kelvin_to_mired(model_specs["color_temp"]["max"]) - self._attr_max_mireds = kelvin_to_mired(model_specs["color_temp"]["min"]) + self._attr_max_color_temp_kelvin = model_specs["color_temp"]["max"] + self._attr_min_color_temp_kelvin = model_specs["color_temp"]["min"] self._light_type = LightType.Main @@ -476,10 +472,10 @@ class YeelightBaseLight(YeelightEntity, LightEntity): return self._predefined_effects + self.custom_effects_names @property - def color_temp(self) -> int | None: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if temp_in_k := self._get_property("ct"): - self._color_temp = kelvin_to_mired(int(temp_in_k)) + self._color_temp = int(temp_in_k) return self._color_temp @property @@ -678,20 +674,19 @@ class YeelightBaseLight(YeelightEntity, LightEntity): ) @_async_cmd - async def async_set_colortemp(self, colortemp, duration) -> None: + async def async_set_colortemp(self, temp_in_k, duration) -> None: """Set bulb's color temperature.""" if ( - not colortemp + not temp_in_k or not self.supported_color_modes or ColorMode.COLOR_TEMP not in self.supported_color_modes ): return - temp_in_k = mired_to_kelvin(colortemp) if ( not self.device.is_color_flow_enabled and self.color_mode == ColorMode.COLOR_TEMP - and self.color_temp == colortemp + and self.color_temp_kelvin == temp_in_k ): _LOGGER.debug("Color temp already set to: %s", temp_in_k) # Already set, and since we get pushed updates @@ -779,7 +774,7 @@ class YeelightBaseLight(YeelightEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - colortemp = kwargs.get(ATTR_COLOR_TEMP) + colortemp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hs_color = kwargs.get(ATTR_HS_COLOR) rgb = kwargs.get(ATTR_RGB_COLOR) flash = kwargs.get(ATTR_FLASH) @@ -933,12 +928,12 @@ class YeelightWithoutNightlightSwitchMixIn(YeelightBaseLight): return super()._brightness_property @property - def color_temp(self) -> int | None: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if self.device.is_nightlight_enabled: # Enabling the nightlight locks the colortemp to max - return self.max_mireds - return super().color_temp + return self.min_color_temp_kelvin + return super().color_temp_kelvin class YeelightColorLightWithoutNightlightSwitch( @@ -1081,8 +1076,8 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): def __init__(self, *args, **kwargs): """Initialize the Yeelight Ambient light.""" super().__init__(*args, **kwargs) - self._attr_min_mireds = kelvin_to_mired(6500) - self._attr_max_mireds = kelvin_to_mired(1700) + self._attr_max_color_temp_kelvin = 6500 + self._attr_min_color_temp_kelvin = 1700 self._light_type = LightType.Ambient diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index 98f1b764498..ff3bbf0d93b 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -63,7 +63,6 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): """YoLink Climate Entity.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index fcf5afb5ac5..af9f56cd7dc 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -88,7 +88,6 @@ class Thermostat(ZHAEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key: str = "thermostat" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: """Initialize the ZHA thermostat entity.""" diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 767c0d4cfb7..73b23e97387 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -47,7 +47,6 @@ class ZhaFan(FanEntity, ZHAEntity): """Representation of a ZHA fan.""" _attr_translation_key: str = "fan" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, entity_data: EntityData) -> None: """Initialize the ZHA fan.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 9a22dfb02e9..2f5d9e9e4c9 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -15,7 +15,7 @@ from zha.application.platforms.light.const import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_TRANSITION, @@ -29,6 +29,7 @@ from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .entity import ZHAEntity from .helpers import ( @@ -128,14 +129,18 @@ class Light(LightEntity, ZHAEntity): return self.entity_data.entity.brightness @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self.entity_data.entity.min_mireds + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self.entity_data.entity.min_mireds + ) @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self.entity_data.entity.max_mireds + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self.entity_data.entity.max_mireds + ) @property def xy_color(self) -> tuple[float, float] | None: @@ -143,9 +148,13 @@ class Light(LightEntity, ZHAEntity): return self.entity_data.entity.xy_color @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self.entity_data.entity.color_temp + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return ( + color_util.color_temperature_mired_to_kelvin(mireds) + if (mireds := self.entity_data.entity.color_temp) + else None + ) @property def color_mode(self) -> ColorMode | None: @@ -167,12 +176,17 @@ class Light(LightEntity, ZHAEntity): @convert_zha_error_to_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" + color_temp = ( + color_util.color_temperature_kelvin_to_mired(color_temp_k) + if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) + else None + ) await self.entity_data.entity.async_turn_on( transition=kwargs.get(ATTR_TRANSITION), brightness=kwargs.get(ATTR_BRIGHTNESS), effect=kwargs.get(ATTR_EFFECT), flash=kwargs.get(ATTR_FLASH), - color_temp=kwargs.get(ATTR_COLOR_TEMP), + color_temp=color_temp, xy_color=kwargs.get(ATTR_XY_COLOR), ) self.async_write_ha_state() @@ -188,12 +202,17 @@ class Light(LightEntity, ZHAEntity): @callback def restore_external_state_attributes(self, state: State) -> None: """Restore entity state.""" + color_temp = ( + color_util.color_temperature_kelvin_to_mired(color_temp_k) + if (color_temp_k := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) + else None + ) self.entity_data.entity.restore_external_state_attributes( state=(state.state == STATE_ON), off_with_transition=state.attributes.get(OFF_WITH_TRANSITION), off_brightness=state.attributes.get(OFF_BRIGHTNESS), brightness=state.attributes.get(ATTR_BRIGHTNESS), - color_temp=state.attributes.get(ATTR_COLOR_TEMP), + color_temp=color_temp, xy_color=state.attributes.get(ATTR_XY_COLOR), color_mode=( HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])] diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 1fbbd83bb9c..3a301be9b02 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.41"], + "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.42"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index eaf00b5432f..b5acc230472 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -135,7 +135,6 @@ class ZhongHongClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hub, addr_out, addr_in): """Set up the ZhongHong climate devices.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 88f8f25c8e2..1a1cd6ae9c1 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -396,6 +396,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_alerts) websocket_api.async_register_command(hass, websocket_add_node) websocket_api.async_register_command(hass, websocket_cancel_secure_bootstrap_s2) + websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion) websocket_api.async_register_command(hass, websocket_grant_security_classes) websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) websocket_api.async_register_command(hass, websocket_provision_smart_start_node) @@ -863,6 +864,40 @@ async def websocket_cancel_secure_bootstrap_s2( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/subscribe_s2_inclusion", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_subscribe_s2_inclusion( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Subscribe to S2 inclusion initiated by the controller.""" + + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + unsub = driver.controller.on("validate dsk and enter pin", forward_dsk) + connection.subscriptions[msg["id"]] = unsub + msg[DATA_UNSUBSCRIBE] = [unsub] + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index c7ab579c2cb..580694cae11 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -128,7 +128,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Representation of a Z-Wave climate.""" _attr_precision = PRECISION_TENTHS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 36f208e18d5..711eb14070d 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -671,7 +671,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.context["source"] == SOURCE_USB: + if not self.unique_id or self.source == SOURCE_USB: if not self.version_info: try: self.version_info = await async_get_version_info( diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 37d3fc57886..d83132e4b95 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -83,7 +83,6 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 4a044ca3f52..e6cfc6c8b29 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -29,7 +29,7 @@ from zwave_js_server.model.value import Value from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGBW_COLOR, ATTR_TRANSITION, @@ -60,6 +60,8 @@ MULTI_COLOR_MAP = { ColorComponent.CYAN: COLOR_SWITCH_COMBINED_CYAN, ColorComponent.PURPLE: COLOR_SWITCH_COMBINED_PURPLE, } +MIN_MIREDS = 153 # 6500K as a safe default +MAX_MIREDS = 370 # 2700K as a safe default async def async_setup_entry( @@ -103,6 +105,9 @@ def byte_to_zwave_brightness(value: int) -> int: class ZwaveLight(ZWaveBaseEntity, LightEntity): """Representation of a Z-Wave light.""" + _attr_min_color_temp_kelvin = 2700 # 370 mireds as a safe default + _attr_max_color_temp_kelvin = 6500 # 153 mireds as a safe default + def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: @@ -116,8 +121,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._hs_color: tuple[float, float] | None = None self._rgbw_color: tuple[int, int, int, int] | None = None self._color_temp: int | None = None - self._min_mireds = 153 # 6500K as a safe default - self._max_mireds = 370 # 2700K as a safe default self._warm_white = self.get_zwave_value( TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, @@ -241,20 +244,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): return self._rgbw_color @property - def color_temp(self) -> int | None: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" return self._color_temp - @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._min_mireds - - @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self._max_mireds - @property def supported_color_modes(self) -> set[ColorMode] | None: """Flag supported features.""" @@ -267,10 +260,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): brightness = kwargs.get(ATTR_BRIGHTNESS) hs_color = kwargs.get(ATTR_HS_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_k = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgbw = kwargs.get(ATTR_RGBW_COLOR) - new_colors = self._get_new_colors(hs_color, color_temp, rgbw) + new_colors = self._get_new_colors(hs_color, color_temp_k, rgbw) if new_colors is not None: await self._async_set_colors(new_colors, transition) @@ -284,7 +277,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): def _get_new_colors( self, hs_color: tuple[float, float] | None, - color_temp: int | None, + color_temp_k: int | None, rgbw: tuple[int, int, int, int] | None, brightness_scale: float | None = None, ) -> dict[ColorComponent, int] | None: @@ -309,17 +302,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): return colors # Color temperature - if color_temp is not None and self._supports_color_temp: + if color_temp_k is not None and self._supports_color_temp: # Limit color temp to min/max values + color_temp = color_util.color_temperature_kelvin_to_mired(color_temp_k) cold = max( 0, min( 255, - round( - (self._max_mireds - color_temp) - / (self._max_mireds - self._min_mireds) - * 255 - ), + round((MAX_MIREDS - color_temp) / (MAX_MIREDS - MIN_MIREDS) * 255), ), ) warm = 255 - cold @@ -505,9 +495,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) # Calculate color temps based on whites if cold_white or warm_white: - self._color_temp = round( - self._max_mireds - - ((cold_white / 255) * (self._max_mireds - self._min_mireds)) + self._color_temp = color_util.color_temperature_mired_to_kelvin( + MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS)) ) # White channels turned on, set color mode to color_temp self._color_mode = ColorMode.COLOR_TEMP @@ -568,7 +557,7 @@ class ZwaveColorOnOffLight(ZwaveLight): if ( kwargs.get(ATTR_RGBW_COLOR) is not None - or kwargs.get(ATTR_COLOR_TEMP) is not None + or kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None ): # RGBW and color temp are not supported in this mode, # delegate to the parent class @@ -629,7 +618,7 @@ class ZwaveColorOnOffLight(ZwaveLight): if new_colors is None: new_colors = self._get_new_colors( - hs_color=hs_color, color_temp=None, rgbw=None, brightness_scale=scale + hs_color=hs_color, color_temp_k=None, rgbw=None, brightness_scale=scale ) if new_colors is not None: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 9938d08408c..db52683c173 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable import functools -from pydantic import ValidationError +from pydantic.v1 import ValidationError import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index de6f606745f..b8eed88b505 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -57,7 +57,6 @@ class ZWaveMeClimate(ZWaveMeEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _enable_turn_on_off_backwards_compatibility = False def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index 1016586ab55..bd0feba0dfb 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -49,7 +49,6 @@ class ZWaveMeFan(ZWaveMeEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ade4cd855ca..d34828f5e46 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -15,6 +15,7 @@ from collections.abc import ( ) from contextvars import ContextVar from copy import deepcopy +from dataclasses import dataclass, field from datetime import datetime from enum import Enum, StrEnum import functools @@ -22,7 +23,7 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, cast +from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, cast from async_interrupt import interrupt from propcache import cached_property @@ -128,7 +129,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry() STORAGE_KEY = "core.config_entries" STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 SAVE_DELAY = 1 @@ -256,6 +257,10 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" +class UnknownSubEntry(ConfigError): + """Unknown subentry specified.""" + + class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" @@ -300,6 +305,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): minor_version: int options: Mapping[str, Any] + subentries: Iterable[ConfigSubentryData] version: int @@ -313,6 +319,51 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) +class ConfigSubentryData(TypedDict): + """Container for configuration subentry data. + + Returned by integrations, a subentry_id will be assigned automatically. + """ + + data: Mapping[str, Any] + title: str + unique_id: str | None + + +class ConfigSubentryDataWithId(ConfigSubentryData): + """Container for configuration subentry data. + + This type is used when loading existing subentries from storage. + """ + + subentry_id: str + + +class SubentryFlowResult(FlowResult[FlowContext, tuple[str, str]], total=False): + """Typed result dict for subentry flow.""" + + unique_id: str | None + + +@dataclass(frozen=True, kw_only=True) +class ConfigSubentry: + """Container for a configuration subentry.""" + + data: MappingProxyType[str, Any] + subentry_id: str = field(default_factory=ulid_util.ulid_now) + title: str + unique_id: str | None + + def as_dict(self) -> ConfigSubentryDataWithId: + """Return dictionary version of this subentry.""" + return { + "data": dict(self.data), + "subentry_id": self.subentry_id, + "title": self.title, + "unique_id": self.unique_id, + } + + class ConfigEntry(Generic[_DataT]): """Hold a configuration entry.""" @@ -322,6 +373,7 @@ class ConfigEntry(Generic[_DataT]): data: MappingProxyType[str, Any] runtime_data: _DataT options: MappingProxyType[str, Any] + subentries: MappingProxyType[str, ConfigSubentry] unique_id: str | None state: ConfigEntryState reason: str | None @@ -337,6 +389,7 @@ class ConfigEntry(Generic[_DataT]): supports_remove_device: bool | None _supports_options: bool | None _supports_reconfigure: bool | None + _supported_subentries: tuple[str, ...] | None update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None @@ -366,6 +419,7 @@ class ConfigEntry(Generic[_DataT]): pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, + subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None, title: str, unique_id: str | None, version: int, @@ -391,6 +445,24 @@ class ConfigEntry(Generic[_DataT]): # Entry options _setter(self, "options", MappingProxyType(options or {})) + # Subentries + subentries_data = subentries_data or () + subentries = {} + for subentry_data in subentries_data: + subentry_kwargs = {} + if "subentry_id" in subentry_data: + # If subentry_data has key "subentry_id", we're loading from storage + subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item] + subentry = ConfigSubentry( + data=MappingProxyType(subentry_data["data"]), + title=subentry_data["title"], + unique_id=subentry_data.get("unique_id"), + **subentry_kwargs, + ) + subentries[subentry.subentry_id] = subentry + + _setter(self, "subentries", MappingProxyType(subentries)) + # Entry system options if pref_disable_new_entities is None: pref_disable_new_entities = False @@ -427,6 +499,9 @@ class ConfigEntry(Generic[_DataT]): # Supports reconfigure _setter(self, "_supports_reconfigure", None) + # Supports subentries + _setter(self, "_supported_subentries", None) + # Listeners to call on update _setter(self, "update_listeners", []) @@ -499,6 +574,18 @@ class ConfigEntry(Generic[_DataT]): ) return self._supports_reconfigure or False + @property + def supported_subentries(self) -> tuple[str, ...]: + """Return supported subentries.""" + if self._supported_subentries is None and ( + handler := HANDLERS.get(self.domain) + ): + # work out sub entries supported by the handler + object.__setattr__( + self, "_supported_subentries", handler.async_supported_subentries(self) + ) + return self._supported_subentries or () + def clear_state_cache(self) -> None: """Clear cached properties that are included in as_json_fragment.""" self.__dict__.pop("as_json_fragment", None) @@ -518,12 +605,14 @@ class ConfigEntry(Generic[_DataT]): "supports_remove_device": self.supports_remove_device or False, "supports_unload": self.supports_unload or False, "supports_reconfigure": self.supports_reconfigure, + "supported_subentries": self.supported_subentries, "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "disabled_by": self.disabled_by, "reason": self.reason, "error_reason_translation_key": self.error_reason_translation_key, "error_reason_translation_placeholders": self.error_reason_translation_placeholders, + "num_subentries": len(self.subentries), } return json_fragment(json_bytes(json_repr)) @@ -1018,6 +1107,7 @@ class ConfigEntry(Generic[_DataT]): "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "source": self.source, + "subentries": [subentry.as_dict() for subentry in self.subentries.values()], "title": self.title, "unique_id": self.unique_id, "version": self.version, @@ -1503,6 +1593,7 @@ class ConfigEntriesFlowManager( minor_version=result["minor_version"], options=result["options"], source=flow.context["source"], + subentries_data=result["subentries"], title=result["title"], unique_id=flow.unique_id, version=result["version"], @@ -1793,6 +1884,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entry in data["entries"]: entry["discovery_keys"] = {} + if old_minor_version < 5: + # Version 1.4 adds config subentries + for entry in data["entries"]: + entry.setdefault("subentries", entry.get("subentries", {})) + if old_major_version > 1: raise NotImplementedError return data @@ -1809,6 +1905,7 @@ class ConfigEntries: self.hass = hass self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) + self.subentries = ConfigSubentryFlowManager(hass) self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) @@ -2011,6 +2108,7 @@ class ConfigEntries: pref_disable_new_entities=entry["pref_disable_new_entities"], pref_disable_polling=entry["pref_disable_polling"], source=entry["source"], + subentries_data=entry["subentries"], title=entry["title"], unique_id=entry["unique_id"], version=entry["version"], @@ -2170,6 +2268,44 @@ class ConfigEntries: If the entry was changed, the update_listeners are fired and this function returns True + If the entry was not changed, the update_listeners are + not fired and this function returns False + """ + return self._async_update_entry( + entry, + data=data, + discovery_keys=discovery_keys, + minor_version=minor_version, + options=options, + pref_disable_new_entities=pref_disable_new_entities, + pref_disable_polling=pref_disable_polling, + title=title, + unique_id=unique_id, + version=version, + ) + + @callback + def _async_update_entry( + self, + entry: ConfigEntry, + *, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] + | UndefinedType = UNDEFINED, + minor_version: int | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + pref_disable_new_entities: bool | UndefinedType = UNDEFINED, + pref_disable_polling: bool | UndefinedType = UNDEFINED, + subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + version: int | UndefinedType = UNDEFINED, + ) -> bool: + """Update a config entry. + + If the entry was changed, the update_listeners are + fired and this function returns True + If the entry was not changed, the update_listeners are not fired and this function returns False """ @@ -2232,6 +2368,11 @@ class ConfigEntries: changed = True _setter(entry, "options", MappingProxyType(options)) + if subentries is not UNDEFINED: + if entry.subentries != subentries: + changed = True + _setter(entry, "subentries", MappingProxyType(subentries)) + if not changed: return False @@ -2249,6 +2390,37 @@ class ConfigEntries: self._async_dispatch(ConfigEntryChange.UPDATED, entry) return True + @callback + def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool: + """Add a subentry to a config entry.""" + self._raise_if_subentry_unique_id_exists(entry, subentry.unique_id) + + return self._async_update_entry( + entry, + subentries=entry.subentries | {subentry.subentry_id: subentry}, + ) + + @callback + def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool: + """Remove a subentry from a config entry.""" + subentries = dict(entry.subentries) + try: + subentries.pop(subentry_id) + except KeyError as err: + raise UnknownSubEntry from err + + return self._async_update_entry(entry, subentries=subentries) + + def _raise_if_subentry_unique_id_exists( + self, entry: ConfigEntry, unique_id: str | None + ) -> None: + """Raise if a subentry with the same unique_id exists.""" + if unique_id is None: + return + for existing_subentry in entry.subentries.values(): + if existing_subentry.unique_id == unique_id: + raise data_entry_flow.AbortFlow("already_configured") + @callback def _async_dispatch( self, change_type: ConfigEntryChange, entry: ConfigEntry @@ -2585,6 +2757,20 @@ class ConfigFlow(ConfigEntryBaseFlow): """Return options flow support for this handler.""" return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + @staticmethod + @callback + def async_get_subentry_flow( + config_entry: ConfigEntry, subentry_type: str + ) -> ConfigSubentryFlow: + """Get the subentry flow for this handler.""" + raise NotImplementedError + + @classmethod + @callback + def async_supported_subentries(cls, config_entry: ConfigEntry) -> tuple[str, ...]: + """Return subentries supported by this handler.""" + return () + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -2893,6 +3079,7 @@ class ConfigFlow(ConfigEntryBaseFlow): description: str | None = None, description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, + subentries: Iterable[ConfigSubentryData] | None = None, ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: @@ -2912,6 +3099,7 @@ class ConfigFlow(ConfigEntryBaseFlow): result["minor_version"] = self.MINOR_VERSION result["options"] = options or {} + result["subentries"] = subentries or () result["version"] = self.VERSION return result @@ -3026,17 +3214,126 @@ class ConfigFlow(ConfigEntryBaseFlow): ) -class OptionsFlowManager( - data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult] -): - """Flow to set options for a configuration entry.""" +class _ConfigSubFlowManager: + """Mixin class for flow managers which manage flows tied to a config entry.""" - _flow_result = ConfigFlowResult + hass: HomeAssistant def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: """Return config entry or raise if not found.""" return self.hass.config_entries.async_get_known_entry(config_entry_id) + +class ConfigSubentryFlowManager( + data_entry_flow.FlowManager[FlowContext, SubentryFlowResult, tuple[str, str]], + _ConfigSubFlowManager, +): + """Manage all the config subentry flows that are in progress.""" + + _flow_result = SubentryFlowResult + + async def async_create_flow( + self, + handler_key: tuple[str, str], + *, + context: FlowContext | None = None, + data: dict[str, Any] | None = None, + ) -> ConfigSubentryFlow: + """Create a subentry flow for a config entry. + + The entry_id and flow.handler[0] is the same thing to map entry with flow. + """ + if not context or "source" not in context: + raise KeyError("Context not set or doesn't have a source set") + + entry_id, subentry_type = handler_key + entry = self._async_get_config_entry(entry_id) + handler = await _async_get_flow_handler(self.hass, entry.domain, {}) + if subentry_type not in handler.async_supported_subentries(entry): + raise data_entry_flow.UnknownHandler( + f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'" + ) + subentry_flow = handler.async_get_subentry_flow(entry, subentry_type) + subentry_flow.init_step = context["source"] + return subentry_flow + + async def async_finish_flow( + self, + flow: data_entry_flow.FlowHandler[ + FlowContext, SubentryFlowResult, tuple[str, str] + ], + result: SubentryFlowResult, + ) -> SubentryFlowResult: + """Finish a subentry flow and add a new subentry to the configuration entry. + + The flow.handler[0] and entry_id is the same thing to map flow with entry. + """ + flow = cast(ConfigSubentryFlow, flow) + + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + return result + + entry_id = flow.handler[0] + entry = self.hass.config_entries.async_get_entry(entry_id) + if entry is None: + raise UnknownEntry(entry_id) + + unique_id = result.get("unique_id") + if unique_id is not None and not isinstance(unique_id, str): + raise HomeAssistantError("unique_id must be a string") + + self.hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(result["data"]), + title=result["title"], + unique_id=unique_id, + ), + ) + + result["result"] = True + return result + + +class ConfigSubentryFlow( + data_entry_flow.FlowHandler[FlowContext, SubentryFlowResult, tuple[str, str]] +): + """Base class for config subentry flows.""" + + _flow_result = SubentryFlowResult + handler: tuple[str, str] + + @callback + def async_create_entry( + self, + *, + title: str | None = None, + data: Mapping[str, Any], + description: str | None = None, + description_placeholders: Mapping[str, str] | None = None, + unique_id: str | None = None, + ) -> SubentryFlowResult: + """Finish config flow and create a config entry.""" + result = super().async_create_entry( + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) + + result["unique_id"] = unique_id + + return result + + +class OptionsFlowManager( + data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult], + _ConfigSubFlowManager, +): + """Manage all the config entry option flows that are in progress.""" + + _flow_result = ConfigFlowResult + async def async_create_flow( self, handler_key: str, @@ -3046,7 +3343,7 @@ class OptionsFlowManager( ) -> OptionsFlow: """Create an options flow for a config entry. - Entry_id and flow.handler is the same thing to map entry with flow. + The entry_id and the flow.handler is the same thing to map entry with flow. """ entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) @@ -3062,7 +3359,7 @@ class OptionsFlowManager( This method is called when a flow step returns FlowResultType.ABORT or FlowResultType.CREATE_ENTRY. - Flow.handler and entry_id is the same thing to map flow with entry. + The flow.handler and the entry_id is the same thing to map flow with entry. """ flow = cast(OptionsFlow, flow) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2eb4194ad15..c026a8e5427 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -619,6 +619,7 @@ class UnitOfEnergy(StrEnum): KILO_JOULE = "kJ" MEGA_JOULE = "MJ" GIGA_JOULE = "GJ" + MILLIWATT_HOUR = "mWh" WATT_HOUR = "Wh" KILO_WATT_HOUR = "kWh" MEGA_WATT_HOUR = "MWh" diff --git a/homeassistant/core.py b/homeassistant/core.py index 0640664d64f..da7a206b14e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2432,10 +2432,11 @@ class Service: class ServiceCall: """Representation of a call to a service.""" - __slots__ = ("domain", "service", "data", "context", "return_response") + __slots__ = ("hass", "domain", "service", "data", "context", "return_response") def __init__( self, + hass: HomeAssistant, domain: str, service: str, data: dict[str, Any] | None = None, @@ -2443,6 +2444,7 @@ class ServiceCall: return_response: bool = False, ) -> None: """Initialize a service call.""" + self.hass = hass self.domain = domain self.service = service self.data = ReadOnlyDict(data or {}) @@ -2768,7 +2770,7 @@ class ServiceRegistry: processed_data = service_data service_call = ServiceCall( - domain, service, processed_data, context, return_response + self._hass, domain, service, processed_data, context, return_response ) self._hass.bus.async_fire_internal( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 430a882ecb9..38ca07e8f31 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -68,11 +68,11 @@ from .util.hass_dict import HassKey from .util.package import is_docker_env from .util.unit_system import ( _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_US_CUSTOMARY, METRIC_SYSTEM, UnitSystem, get_unit_system, - validate_unit_system, ) # Typing imports that create a circular dependency @@ -188,6 +188,26 @@ _CUSTOMIZE_CONFIG_SCHEMA = vol.Schema( ) +def _raise_issue_if_imperial_unit_system( + hass: HomeAssistant, config: dict[str, Any] +) -> dict[str, Any]: + if config.get(CONF_UNIT_SYSTEM) == _CONF_UNIT_SYSTEM_IMPERIAL: + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + "imperial_unit_system", + is_fixable=False, + learn_more_url="homeassistant://config/general", + severity=ir.IssueSeverity.WARNING, + translation_key="imperial_unit_system", + ) + config[CONF_UNIT_SYSTEM] = _CONF_UNIT_SYSTEM_US_CUSTOMARY + else: + ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN, "imperial_unit_system") + + return config + + def _raise_issue_if_historic_currency(hass: HomeAssistant, currency: str) -> None: if currency not in HISTORIC_CURRENCIES: ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN, "historic_currency") @@ -249,7 +269,11 @@ CORE_CONFIG_SCHEMA = vol.All( CONF_ELEVATION: vol.Coerce(int), CONF_RADIUS: cv.positive_int, vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit, - CONF_UNIT_SYSTEM: validate_unit_system, + CONF_UNIT_SYSTEM: vol.Any( + _CONF_UNIT_SYSTEM_METRIC, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + _CONF_UNIT_SYSTEM_IMPERIAL, + ), CONF_TIME_ZONE: cv.time_zone, vol.Optional(CONF_INTERNAL_URL): cv.url, vol.Optional(CONF_EXTERNAL_URL): cv.url, @@ -333,6 +357,9 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non # so we need to run it in an executor job. config = await hass.async_add_executor_job(CORE_CONFIG_SCHEMA, config) + # Check if we need to raise an issue for imperial unit system + config = _raise_issue_if_imperial_unit_system(hass, config) + # Only load auth during startup. if not hasattr(hass, "auth"): if (auth_conf := config.get(CONF_AUTH_PROVIDERS)) is None: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9a75ac32ea1..8e88e8a2ae8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = { "color_extractor", "comelit", "control4", + "cookidoo", "coolmaster", "cpuspeed", "crownstone", @@ -154,6 +155,7 @@ FLOWS = { "ecowitt", "edl21", "efergy", + "eheimdigital", "electrasmart", "electric_kiwi", "elevenlabs", @@ -296,6 +298,7 @@ FLOWS = { "iss", "ista_ecotrend", "isy994", + "ituran", "izone", "jellyfin", "jewish_calendar", @@ -420,6 +423,7 @@ FLOWS = { "nzbget", "obihai", "octoprint", + "ohme", "ollama", "omnilogic", "oncue", @@ -461,6 +465,7 @@ FLOWS = { "plum_lightpad", "point", "poolsense", + "powerfox", "powerwall", "private_ble_device", "profiler", @@ -543,6 +548,7 @@ FLOWS = { "skybell", "slack", "sleepiq", + "slide_local", "slimproto", "sma", "smappee", @@ -573,7 +579,6 @@ FLOWS = { "starlink", "steam_online", "steamist", - "stookalert", "stookwijzer", "streamlabswater", "subaru", @@ -665,6 +670,7 @@ FLOWS = { "wake_on_lan", "wallbox", "waqi", + "watergate", "watttime", "waze_travel_time", "weatherflow", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index e37fb2332b1..22a09945a80 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -209,6 +209,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "fully_kiosk", "registered_devices": True, }, + { + "domain": "fyta", + "hostname": "fyta*", + }, { "domain": "goalzero", "registered_devices": True, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ae7e0dd6c59..a94962b458b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1044,6 +1044,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "cookidoo": { + "name": "Cookidoo", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "coolmaster": { "name": "CoolMasterNet", "integration_type": "hub", @@ -1374,12 +1380,6 @@ "config_flow": true, "iot_class": "local_push" }, - "dte_energy_bridge": { - "name": "DTE Energy Bridge", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "dublin_bus_transport": { "name": "Dublin Bus", "integration_type": "hub", @@ -1524,6 +1524,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "eheimdigital": { + "name": "EHEIM Digital", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", @@ -2989,6 +2995,12 @@ "config_flow": true, "iot_class": "local_push" }, + "ituran": { + "name": "Ituran", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "izone": { "name": "iZone", "integration_type": "hub", @@ -4317,6 +4329,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "ohme": { + "name": "Ohme", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ollama": { "name": "Ollama", "integration_type": "service", @@ -4763,6 +4781,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "powerfox": { + "name": "Powerfox", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "private_ble_device": { "name": "Private BLE Device", "integration_type": "hub", @@ -5599,12 +5623,6 @@ "integration_type": "virtual", "supported_by": "overkiz" }, - "simulated": { - "name": "Simulated", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "sinch": { "name": "Sinch SMS", "integration_type": "hub", @@ -5660,9 +5678,20 @@ }, "slide": { "name": "Slide", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" + "integrations": { + "slide": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "cloud_polling", + "name": "Slide" + }, + "slide_local": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Slide Local" + } + } }, "slimproto": { "name": "SlimProto (Squeezebox players)", @@ -5957,12 +5986,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "stookalert": { - "name": "RIVM Stookalert", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, "stookwijzer": { "name": "Stookwijzer", "integration_type": "service", @@ -6904,6 +6927,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "watergate": { + "name": "Watergate", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "watttime": { "name": "WattTime", "integration_type": "service", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 5f7161a8245..2c914c2d240 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -92,6 +92,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Colour": { + "always_discover": True, + "domain": "lifx", + }, "LIFX DLCOL": { "always_discover": True, "domain": "lifx", @@ -140,6 +144,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Permanent Outdoor": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Pls": { "always_discover": True, "domain": "lifx", @@ -164,6 +172,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Tube": { + "always_discover": True, + "domain": "lifx", + }, "LIFX White": { "always_discover": True, "domain": "lifx", @@ -524,6 +536,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "eheimdigital", + "name": "eheimdigital._http._tcp.local.", + }, { "domain": "lektrico", "name": "lektrico*", @@ -542,6 +558,10 @@ ZEROCONF = { "manufacturer": "nettigo", }, }, + { + "domain": "powerfox", + "name": "powerfox*", + }, { "domain": "pure_energie", "name": "smartbridge*", @@ -558,6 +578,10 @@ ZEROCONF = { "domain": "shelly", "name": "shelly*", }, + { + "domain": "slide_local", + "name": "slide*", + }, { "domain": "synology_dsm", "properties": { diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index adb2062a8ea..e98061d50b7 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -18,7 +18,7 @@ from . import config_validation as cv _FlowManagerT = TypeVar( "_FlowManagerT", - bound=data_entry_flow.FlowManager[Any, Any], + bound=data_entry_flow.FlowManager[Any, Any, Any], default=data_entry_flow.FlowManager, ) @@ -71,7 +71,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Initialize a POST request. - Override `_post_impl` in subclasses which need + Override `post` and call `_post_impl` in subclasses which need to implement their own `RequestDataValidator` """ return await self._post_impl(request, data) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 19076c4edc0..91845cdf521 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses -from enum import Enum, IntFlag, auto +from enum import Enum, auto import functools as ft import logging import math @@ -1639,31 +1639,6 @@ class Entity( self.hass, integration_domain=platform_name, module=type(self).__module__ ) - @callback - def _report_deprecated_supported_features_values( - self, replacement: IntFlag - ) -> None: - """Report deprecated supported features values.""" - if self._deprecated_supported_features_reported is True: - return - self._deprecated_supported_features_reported = True - report_issue = self._suggest_report_issue() - report_issue += ( - " and reference " - "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" - ) - _LOGGER.warning( - ( - "Entity %s (%s) is using deprecated supported features" - " values which will be removed in HA Core 2025.1. Instead it should use" - " %s, please %s" - ), - self.entity_id, - type(self), - repr(replacement), - report_issue, - ) - class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 578132f358f..72a4ef3c050 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -90,7 +90,6 @@ RANDOM_MICROSECOND_MIN = 50000 RANDOM_MICROSECOND_MAX = 500000 _TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any]) -_StateEventDataT = TypeVar("_StateEventDataT", bound=EventStateEventData) @dataclass(slots=True, frozen=True) @@ -333,7 +332,7 @@ def async_track_state_change_event( @callback -def _async_dispatch_entity_id_event_soon( +def _async_dispatch_entity_id_event_soon[_StateEventDataT: EventStateEventData]( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], event: Event[_StateEventDataT], @@ -343,7 +342,7 @@ def _async_dispatch_entity_id_event_soon( @callback -def _async_dispatch_entity_id_event( +def _async_dispatch_entity_id_event[_StateEventDataT: EventStateEventData]( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], event: Event[_StateEventDataT], @@ -363,7 +362,7 @@ def _async_dispatch_entity_id_event( @callback -def _async_state_filter( +def _async_state_filter[_StateEventDataT: EventStateEventData]( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], event_data: _StateEventDataT, diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py index 6ffc981ced1..a5284807617 100644 --- a/homeassistant/helpers/service_info/mqtt.py +++ b/homeassistant/helpers/service_info/mqtt.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from homeassistant.data_entry_flow import BaseServiceInfo -type ReceivePayloadType = str | bytes +type ReceivePayloadType = str | bytes | bytearray @dataclass(slots=True) diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index df4c45cd5ed..53866428332 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -71,7 +71,10 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: try: info_object["user"] = cached_get_user() - except KeyError: + except (KeyError, OSError): + # OSError on python >= 3.13, KeyError on python < 3.13 + # KeyError can be removed when 3.12 support is dropped + # see https://docs.python.org/3/whatsnew/3.13.html info_object["user"] = None if platform.system() == "Darwin": diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 503937a44cb..65a6890024f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,9 +3,9 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.1 +aiohasupervisor==0.2.2b0 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.9 +aiohttp==3.11.10 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 @@ -24,18 +24,18 @@ bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.20.0 cached-ipaddress==0.8.0 certifi>=2021.5.30 -ciso8601==2.3.1 +ciso8601==2.3.2 cryptography==44.0.0 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.1.1 +go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.6.0 -hass-nabucasa==0.85.0 +hass-nabucasa==0.87.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241127.3 -home-assistant-intents==2024.12.2 +home-assistant-frontend==20241127.8 +home-assistant-intents==2024.12.9 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 @@ -65,7 +65,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.5.4 +uv==0.5.8 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 @@ -115,7 +115,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.3 +numpy==2.2.0 pandas~=2.2.3 # Constrain multidict to avoid typing issues @@ -125,9 +125,8 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Required to avoid breaking (#101042). -# v2 has breaking changes (#99218). -pydantic==1.10.19 +# ensure pydantic version does not float since it might have breaking changes +pydantic==2.10.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -205,8 +204,3 @@ 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 - -# 0.22.0 causes CI failures on Python 3.13 -# python3 -X dev -m pytest tests/components/matrix -# python3 -X dev -m pytest tests/components/zha -rpds-py==0.21.0 diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 3cffcb5768e..8bf6d4b9fc9 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -266,6 +266,7 @@ class EnergyConverter(BaseUnitConverter): UnitOfEnergy.KILO_JOULE: _WH_TO_J, UnitOfEnergy.MEGA_JOULE: _WH_TO_J / 1e3, UnitOfEnergy.GIGA_JOULE: _WH_TO_J / 1e6, + UnitOfEnergy.MILLIWATT_HOUR: 1e6, UnitOfEnergy.WATT_HOUR: 1e3, UnitOfEnergy.KILO_WATT_HOUR: 1, UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1e3, diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index c812dd38230..15993cbae47 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -233,7 +233,6 @@ def _deprecated_unit_system(value: str) -> str: """Convert deprecated unit system.""" if value == _CONF_UNIT_SYSTEM_IMPERIAL: - # need to add warning in 2023.1 return _CONF_UNIT_SYSTEM_US_CUSTOMARY return value diff --git a/mypy.ini b/mypy.ini index 22e85244843..15b96e0a802 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,18 +5,18 @@ [mypy] python_version = 3.12 platform = linux -plugins = pydantic.mypy +plugins = pydantic.mypy, pydantic.v1.mypy show_error_codes = true follow_imports = normal local_partial_types = true strict_equality = true +strict_bytes = true no_implicit_optional = true -report_deprecated_as_error = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true -enable_error_code = ignore-without-code, redundant-self, truthy-iterable +enable_error_code = deprecated, ignore-without-code, redundant-self, truthy-iterable disable_error_code = annotation-unchecked, import-not-found, import-untyped extra_checks = false check_untyped_defs = true @@ -1125,6 +1125,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.cookidoo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.counter.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1446,6 +1456,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.eheimdigital.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.electrasmart.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2446,6 +2466,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.iron_os.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.islamic_prayer_times.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3406,6 +3436,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.powerfox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3766,6 +3806,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.russound_rio.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ruuvi_gateway.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4147,16 +4197,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.stookalert.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.stookwijzer.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 1cd7cb878d6..2930d381d2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,8 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.1", - "aiohttp==3.11.9", + "aiohasupervisor==0.2.2b0", + "aiohttp==3.11.10", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", @@ -41,11 +41,11 @@ dependencies = [ "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", - "ciso8601==2.3.1", + "ciso8601==2.3.2", "fnv-hash-fast==1.0.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.85.0", + "hass-nabucasa==0.87.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.2", @@ -75,7 +75,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.4", + "uv==0.5.8", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", @@ -527,8 +527,6 @@ filterwarnings = [ # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", - # https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", diff --git a/requirements.txt b/requirements.txt index e4aa6dc121a..e80804569d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,8 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.1 -aiohttp==3.11.9 +aiohasupervisor==0.2.2b0 +aiohttp==3.11.10 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 @@ -17,9 +17,9 @@ audioop-lts==0.2.1;python_version>='3.13' awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 -ciso8601==2.3.1 +ciso8601==2.3.2 fnv-hash-fast==1.0.2 -hass-nabucasa==0.85.0 +hass-nabucasa==0.87.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 @@ -43,7 +43,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.5.4 +uv==0.5.8 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/requirements_all.txt b/requirements_all.txt index 06e184246b2..c4e9529c6c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.3 # homeassistant.components.honeywell -AIOSomecomfort==0.0.25 +AIOSomecomfort==0.0.28 # homeassistant.components.adax Adax-local==0.1.5 @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.35.0 +PyViCare==2.38.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.10 +aioacaia==0.1.11 # homeassistant.components.airq aioairq==0.4.3 @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.10.3 +aioautomower==2024.12.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.3 +aioesphomeapi==28.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -252,7 +252,6 @@ aioflo==2021.11.0 aioftp==0.21.3 # homeassistant.components.github -# homeassistant.components.iron_os aiogithubapi==24.6.0 # homeassistant.components.guardian @@ -262,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.2.1 +aiohasupervisor==0.2.2b0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -286,7 +285,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.5.5 # homeassistant.components.lifx -aiolifx==1.1.1 +aiolifx==1.1.2 # homeassistant.components.lookin aiolookin==1.0.0 @@ -328,7 +327,7 @@ aiopegelonline==0.1.0 aiopulse==0.4.6 # homeassistant.components.purpleair -aiopurpleair==2022.12.1 +aiopurpleair==2023.12.0 # homeassistant.components.hunterdouglas_powerview aiopvapi==3.1.1 @@ -384,7 +383,7 @@ aiosteamist==1.0.0 aiostreammagic==2.10.0 # homeassistant.components.switcher_kis -aioswitcher==5.0.0 +aioswitcher==5.1.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -399,7 +398,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==80 +aiounifi==81 # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -417,7 +416,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.3 +aiowithings==3.1.4 # homeassistant.components.yandex_transport aioymaps==1.2.5 @@ -540,7 +539,7 @@ av==13.1.0 # avion==0.10 # homeassistant.components.axis -axis==63 +axis==64 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.4 @@ -704,6 +703,10 @@ connect-box==0.3.1 # homeassistant.components.xiaomi_miio construct==2.10.68 +# homeassistant.components.cookidoo +cookidoo-api==0.10.0 + +# homeassistant.components.backup # homeassistant.components.utility_meter cronsim==2.6 @@ -726,7 +729,7 @@ datapoint==0.9.9 dbus-fast==2.24.3 # homeassistant.components.debugpy -debugpy==1.8.6 +debugpy==1.8.11 # homeassistant.components.decora_wifi # decora-wifi==1.4 @@ -735,7 +738,7 @@ debugpy==1.8.6 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==9.0.0 +deebot-client==9.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -746,7 +749,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==0.4.0 +demetriek==1.1.0 # homeassistant.components.denonavr denonavr==1.0.1 @@ -805,11 +808,14 @@ ebusdpy==0.0.17 # homeassistant.components.ecoal_boiler ecoaliface==0.4.0 +# homeassistant.components.eheimdigital +eheimdigital==1.0.3 + # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elevenlabs -elevenlabs==1.6.1 +elevenlabs==1.9.0 # homeassistant.components.elgato elgato==5.1.2 @@ -821,7 +827,7 @@ eliqonline==1.2.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.6.1 +elmax-api==0.0.6.3 # homeassistant.components.elvia elvia==0.1.0 @@ -875,7 +881,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.20 +evohome-async==0.4.21 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -993,7 +999,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.1 +go2rtc-client==0.1.2 # homeassistant.components.goalzero goalzero==0.2.2 @@ -1027,10 +1033,11 @@ google-photos-library-api==0.12.1 googlemaps==2.5.1 # homeassistant.components.slide +# homeassistant.components.slide_local goslide-api==0.7.0 # homeassistant.components.tailwind -gotailwind==0.2.4 +gotailwind==0.3.0 # homeassistant.components.govee_ble govee-ble==0.40.0 @@ -1087,7 +1094,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.85.0 +hass-nabucasa==0.87.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1124,19 +1131,19 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.61 +holidays==0.62 # homeassistant.components.frontend -home-assistant-frontend==20241127.3 +home-assistant-frontend==20241127.8 # homeassistant.components.conversation -home-assistant-intents==2024.12.2 +home-assistant-intents==2024.12.9 # homeassistant.components.home_connect homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.3 +homematicip==1.1.5 # homeassistant.components.horizon horimote==0.4.1 @@ -1186,10 +1193,10 @@ iglo==1.2.7 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.6 +imgw_pib==1.0.7 # homeassistant.components.incomfort -incomfort-client==0.6.3-1 +incomfort-client==0.6.4 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1207,7 +1214,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iotty -iottycloud==0.2.1 +iottycloud==0.3.0 # homeassistant.components.iperf3 iperf3==0.1.11 @@ -1247,7 +1254,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knocki -knocki==0.3.5 +knocki==0.4.2 # homeassistant.components.knx knx-frontend==2024.11.16.205004 @@ -1277,7 +1284,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.0.2 +led-ble==1.1.1 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1403,7 +1410,7 @@ motionblindsble==0.1.3 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==4.1.1.116.3 +mozart-api==4.1.1.116.4 # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1433,7 +1440,7 @@ ndms2-client==0.1.2 nessclient==1.1.2 # homeassistant.components.netdata -netdata==1.1.0 +netdata==1.3.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -1457,10 +1464,10 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.nibe_heatpump -nibe==2.13.0 +nibe==2.14.0 # homeassistant.components.nice_go -nice-go==0.3.10 +nice-go==1.0.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 @@ -1478,7 +1485,7 @@ notifications-android-tv==0.1.5 notify-events==1.0.4 # homeassistant.components.nederlandse_spoorwegen -nsapi==3.0.5 +nsapi==3.1.2 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.1.0 @@ -1494,7 +1501,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.3 +numpy==2.2.0 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1514,6 +1521,9 @@ odp-amsterdam==6.0.2 # homeassistant.components.oem oemthermostat==1.1.1 +# homeassistant.components.ohme +ohme==1.1.1 + # homeassistant.components.ollama ollama==0.3.3 @@ -1524,13 +1534,13 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.12 +onvif-zeep-async==3.1.13 # homeassistant.components.opengarage open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.3.1 +open-meteo==0.3.2 # homeassistant.components.openai_conversation openai==1.35.7 @@ -1548,7 +1558,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.2.7 +openwebifpy==4.3.0 # homeassistant.components.luci openwrt-luci-rpc==1.1.17 @@ -1622,7 +1632,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.1 +plugwise==1.6.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1633,6 +1643,9 @@ pmsensor==0.4 # homeassistant.components.poolsense poolsense==0.0.8 +# homeassistant.components.powerfox +powerfox==1.0.0 + # homeassistant.components.reddit praw==7.5.0 @@ -1659,9 +1672,6 @@ psutil==6.1.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 -# homeassistant.components.androidtv -pure-python-adb[async]==0.3.0.dev0 - # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -1669,10 +1679,10 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.1 +pvo==2.2.0 # homeassistant.components.aosmith -py-aosmith==1.0.11 +py-aosmith==1.0.12 # homeassistant.components.canary py-canary==0.5.4 @@ -1781,7 +1791,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.0.15 +pyaussiebb==0.1.4 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1832,16 +1842,16 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.cups -# pycups==1.9.73 +# pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.13.7 +pydaikin==2.13.8 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deako -pydeako==0.5.4 +pydeako==0.6.0 # homeassistant.components.deconz pydeconz==118 @@ -1907,7 +1917,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.2.3 +pyezviz==0.2.1.2 # homeassistant.components.fibaro pyfibaro==0.8.0 @@ -1996,6 +2006,9 @@ pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 +# homeassistant.components.ituran +pyituran==0.1.4 + # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 @@ -2027,7 +2040,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.2.12 +pylamarzocco==1.4.0 # homeassistant.components.lastfm pylast==5.1.0 @@ -2078,7 +2091,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.6.9 +pymodbus==3.7.4 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2090,7 +2103,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==1.0.1 +pynecil==2.1.0 # homeassistant.components.netgear pynetgear==0.10.10 @@ -2102,7 +2115,7 @@ pynetio==0.1.9.1 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.2 +pynordpool==0.2.3 # homeassistant.components.nuki pynuki==1.6.3 @@ -2203,7 +2216,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.4 +pyrisco==0.6.5 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -2293,7 +2306,7 @@ pysqueezebox==0.10.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==1.3.2 +pysuezV2==1.3.5 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2362,10 +2375,10 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.8.0 +python-kasa[speedups]==0.8.1 # homeassistant.components.linkplay -python-linkplay==0.0.20 +python-linkplay==0.1.1 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2411,7 +2424,7 @@ python-smarttub==0.0.38 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.7 +python-tado==0.17.6 # homeassistant.components.technove python-technove==1.3.1 @@ -2556,7 +2569,7 @@ renault-api==0.2.8 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.4 +reolink-aio==0.11.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2610,7 +2623,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.7.1 +samsungtvws[async,encrypted]==2.7.2 # homeassistant.components.sanix sanix==1.0.6 @@ -2734,7 +2747,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.2.0 +starlink-grpc-core==1.2.2 # homeassistant.components.statsd statsd==3.2.1 @@ -2742,9 +2755,6 @@ statsd==3.2.1 # homeassistant.components.steam_online steamodd==4.21 -# homeassistant.components.stookalert -stookalert==0.1.4 - # homeassistant.components.stookwijzer stookwijzer==1.5.1 @@ -2810,7 +2820,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.8.4 +tesla-fleet-api==0.8.5 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2837,7 +2847,7 @@ thermopro-ble==0.10.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.1 +thinqconnect==1.0.2 # homeassistant.components.tikteck tikteck==0.4 @@ -2858,7 +2868,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.5 +total-connect-client==2024.12 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2882,7 +2892,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.1.0 +twentemilieu==2.2.0 # homeassistant.components.twilio twilio==6.32.0 @@ -2897,7 +2907,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.5 +uiprotect==7.0.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2915,7 +2925,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.25 # homeassistant.components.upb -upb-lib==0.5.8 +upb-lib==0.5.9 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2938,7 +2948,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.11.1 +velbus-aio==2024.12.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2975,11 +2985,14 @@ wakeonlan==2.1.0 wallbox==0.7.0 # homeassistant.components.folder_watcher -watchdog==2.3.1 +watchdog==6.0.0 # homeassistant.components.waterfurnace waterfurnace==1.1.0 +# homeassistant.components.watergate +watergate-local-api==2024.4.1 + # homeassistant.components.weatherflow_cloud weatherflow4py==1.0.6 @@ -3008,7 +3021,7 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.20.2 +wled==0.21.0 # homeassistant.components.wolflink wolf-comm==0.0.15 @@ -3017,13 +3030,13 @@ wolf-comm==0.0.15 wyoming==1.5.4 # homeassistant.components.xbox -xbox-webapi==2.0.11 +xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.3.0 +xknx==3.4.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -3044,7 +3057,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.1 +yalexs-ble==2.5.5 # homeassistant.components.august # homeassistant.components.yale @@ -3066,7 +3079,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.18 +yt-dlp[default]==2024.12.13 # homeassistant.components.zamg zamg==0.3.6 @@ -3081,7 +3094,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.41 +zha==0.0.42 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 34dcdfc1244..98a948cd56e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,15 +7,15 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.5 +astroid==3.3.6 coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.14.0a5 +mypy-dev==1.14.0a7 pre-commit==4.0.0 -pydantic==1.10.19 -pylint==3.3.1 +pydantic==2.10.3 +pylint==3.3.2 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 pytest-asyncio==0.24.0 @@ -29,7 +29,7 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.1 pytest-picked==0.5.0 pytest-xdist==3.6.1 -pytest==8.3.3 +pytest==8.3.4 requests-mock==1.12.1 respx==0.21.1 syrupy==4.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52dcb44e47d..056d7422195 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.3 # homeassistant.components.honeywell -AIOSomecomfort==0.0.25 +AIOSomecomfort==0.0.28 # homeassistant.components.adax Adax-local==0.1.5 @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.35.0 +PyViCare==2.38.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.10 +aioacaia==0.1.11 # homeassistant.components.airq aioairq==0.4.3 @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.10.3 +aioautomower==2024.12.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -231,13 +231,12 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.3 +aioesphomeapi==28.0.0 # homeassistant.components.flo aioflo==2021.11.0 # homeassistant.components.github -# homeassistant.components.iron_os aiogithubapi==24.6.0 # homeassistant.components.guardian @@ -247,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.2.1 +aiohasupervisor==0.2.2b0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -268,7 +267,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.5.5 # homeassistant.components.lifx -aiolifx==1.1.1 +aiolifx==1.1.2 # homeassistant.components.lookin aiolookin==1.0.0 @@ -310,7 +309,7 @@ aiopegelonline==0.1.0 aiopulse==0.4.6 # homeassistant.components.purpleair -aiopurpleair==2022.12.1 +aiopurpleair==2023.12.0 # homeassistant.components.hunterdouglas_powerview aiopvapi==3.1.1 @@ -366,7 +365,7 @@ aiosteamist==1.0.0 aiostreammagic==2.10.0 # homeassistant.components.switcher_kis -aioswitcher==5.0.0 +aioswitcher==5.1.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -381,7 +380,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==80 +aiounifi==81 # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -399,7 +398,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.3 +aiowithings==3.1.4 # homeassistant.components.yandex_transport aioymaps==1.2.5 @@ -489,7 +488,7 @@ automower-ble==0.2.0 av==13.1.0 # homeassistant.components.axis -axis==63 +axis==64 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.4 @@ -600,6 +599,10 @@ colorthief==0.2.1 # homeassistant.components.xiaomi_miio construct==2.10.68 +# homeassistant.components.cookidoo +cookidoo-api==0.10.0 + +# homeassistant.components.backup # homeassistant.components.utility_meter cronsim==2.6 @@ -622,10 +625,10 @@ datapoint==0.9.9 dbus-fast==2.24.3 # homeassistant.components.debugpy -debugpy==1.8.6 +debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==9.0.0 +deebot-client==9.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -636,7 +639,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==0.4.0 +demetriek==1.1.0 # homeassistant.components.denonavr denonavr==1.0.1 @@ -683,11 +686,14 @@ eagle100==0.1.1 # homeassistant.components.easyenergy easyenergy==2.1.2 +# homeassistant.components.eheimdigital +eheimdigital==1.0.3 + # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elevenlabs -elevenlabs==1.6.1 +elevenlabs==1.9.0 # homeassistant.components.elgato elgato==5.1.2 @@ -696,7 +702,7 @@ elgato==5.1.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.6.1 +elmax-api==0.0.6.3 # homeassistant.components.elvia elvia==0.1.0 @@ -741,7 +747,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==0.4.20 +evohome-async==0.4.21 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -843,7 +849,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.1 +go2rtc-client==0.1.2 # homeassistant.components.goalzero goalzero==0.2.2 @@ -876,8 +882,12 @@ google-photos-library-api==0.12.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.slide +# homeassistant.components.slide_local +goslide-api==0.7.0 + # homeassistant.components.tailwind -gotailwind==0.2.4 +gotailwind==0.3.0 # homeassistant.components.govee_ble govee-ble==0.40.0 @@ -925,7 +935,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.85.0 +hass-nabucasa==0.87.0 # homeassistant.components.conversation hassil==2.0.5 @@ -950,19 +960,19 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.61 +holidays==0.62 # homeassistant.components.frontend -home-assistant-frontend==20241127.3 +home-assistant-frontend==20241127.8 # homeassistant.components.conversation -home-assistant-intents==2024.12.2 +home-assistant-intents==2024.12.9 # homeassistant.components.home_connect homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.3 +homematicip==1.1.5 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -997,10 +1007,10 @@ idasen-ha==2.6.2 ifaddr==0.2.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.6 +imgw_pib==1.0.7 # homeassistant.components.incomfort -incomfort-client==0.6.3-1 +incomfort-client==0.6.4 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1018,7 +1028,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iotty -iottycloud==0.2.1 +iottycloud==0.3.0 # homeassistant.components.isal isal==1.7.1 @@ -1046,7 +1056,7 @@ justnimbus==0.7.4 kegtron-ble==0.4.0 # homeassistant.components.knocki -knocki==0.3.5 +knocki==0.4.2 # homeassistant.components.knx knx-frontend==2024.11.16.205004 @@ -1073,7 +1083,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.0.2 +led-ble==1.1.1 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1172,7 +1182,7 @@ motionblindsble==0.1.3 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==4.1.1.116.3 +mozart-api==4.1.1.116.4 # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1217,10 +1227,10 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.nibe_heatpump -nibe==2.13.0 +nibe==2.14.0 # homeassistant.components.nice_go -nice-go==0.3.10 +nice-go==1.0.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 @@ -1245,7 +1255,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.3 +numpy==2.2.0 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1259,6 +1269,9 @@ objgraph==3.5.0 # homeassistant.components.garages_amsterdam odp-amsterdam==6.0.2 +# homeassistant.components.ohme +ohme==1.1.1 + # homeassistant.components.ollama ollama==0.3.3 @@ -1269,13 +1282,13 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.12 +onvif-zeep-async==3.1.13 # homeassistant.components.opengarage open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.3.1 +open-meteo==0.3.2 # homeassistant.components.openai_conversation openai==1.35.7 @@ -1287,7 +1300,7 @@ openerz-api==0.3.0 openhomedevice==2.2.0 # homeassistant.components.enigma2 -openwebifpy==4.2.7 +openwebifpy==4.3.0 # homeassistant.components.opower opower==0.8.6 @@ -1332,7 +1345,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.1 +plugwise==1.6.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1340,6 +1353,9 @@ plumlightpad==0.0.11 # homeassistant.components.poolsense poolsense==0.0.8 +# homeassistant.components.powerfox +powerfox==1.0.0 + # homeassistant.components.reddit praw==7.5.0 @@ -1357,9 +1373,6 @@ psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor psutil==6.1.0 -# homeassistant.components.androidtv -pure-python-adb[async]==0.3.0.dev0 - # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -1367,10 +1380,10 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.1 +pvo==2.2.0 # homeassistant.components.aosmith -py-aosmith==1.0.11 +py-aosmith==1.0.12 # homeassistant.components.canary py-canary==0.5.4 @@ -1455,7 +1468,7 @@ pyatmo==8.1.0 pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.0.15 +pyaussiebb==0.1.4 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1488,10 +1501,10 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.7 +pydaikin==2.13.8 # homeassistant.components.deako -pydeako==0.5.4 +pydeako==0.6.0 # homeassistant.components.deconz pydeconz==118 @@ -1539,7 +1552,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.2.3 +pyezviz==0.2.1.2 # homeassistant.components.fibaro pyfibaro==0.8.0 @@ -1610,6 +1623,9 @@ pyiss==1.0.1 # homeassistant.components.isy994 pyisy==3.1.14 +# homeassistant.components.ituran +pyituran==0.1.4 + # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 @@ -1635,7 +1651,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.2.12 +pylamarzocco==1.4.0 # homeassistant.components.lastfm pylast==5.1.0 @@ -1680,7 +1696,7 @@ pymicro-vad==1.0.1 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.6.9 +pymodbus==3.7.4 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1689,7 +1705,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==1.0.1 +pynecil==2.1.0 # homeassistant.components.netgear pynetgear==0.10.10 @@ -1698,7 +1714,7 @@ pynetgear==0.10.10 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.2 +pynordpool==0.2.3 # homeassistant.components.nuki pynuki==1.6.3 @@ -1778,7 +1794,7 @@ pyqwikswitch==0.93 pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.4 +pyrisco==0.6.5 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1853,7 +1869,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.10.0 # homeassistant.components.suez_water -pysuezV2==1.3.2 +pysuezV2==1.3.5 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -1892,10 +1908,10 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.8.0 +python-kasa[speedups]==0.8.1 # homeassistant.components.linkplay -python-linkplay==0.0.20 +python-linkplay==0.1.1 # homeassistant.components.matter python-matter-server==6.6.0 @@ -1935,7 +1951,7 @@ python-smarttub==0.0.38 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.7 +python-tado==0.17.6 # homeassistant.components.technove python-technove==1.3.1 @@ -2050,7 +2066,7 @@ renault-api==0.2.8 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.4 +reolink-aio==0.11.5 # homeassistant.components.rflink rflink==0.0.66 @@ -2089,7 +2105,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.7.1 +samsungtvws[async,encrypted]==2.7.2 # homeassistant.components.sanix sanix==1.0.6 @@ -2186,7 +2202,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.2.0 +starlink-grpc-core==1.2.2 # homeassistant.components.statsd statsd==3.2.1 @@ -2194,9 +2210,6 @@ statsd==3.2.1 # homeassistant.components.steam_online steamodd==4.21 -# homeassistant.components.stookalert -stookalert==0.1.4 - # homeassistant.components.stookwijzer stookwijzer==1.5.1 @@ -2241,7 +2254,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.8.4 +tesla-fleet-api==0.8.5 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2262,7 +2275,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.1 +thinqconnect==1.0.2 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -2277,7 +2290,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.5 +total-connect-client==2024.12 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 @@ -2298,7 +2311,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.1.0 +twentemilieu==2.2.0 # homeassistant.components.twilio twilio==6.32.0 @@ -2313,7 +2326,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.5 +uiprotect==7.0.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2325,7 +2338,7 @@ unifi-discovery==1.2.0 universal-silabs-flasher==0.0.25 # homeassistant.components.upb -upb-lib==0.5.8 +upb-lib==0.5.9 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2348,7 +2361,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.11.1 +velbus-aio==2024.12.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2379,7 +2392,10 @@ wakeonlan==2.1.0 wallbox==0.7.0 # homeassistant.components.folder_watcher -watchdog==2.3.1 +watchdog==6.0.0 + +# homeassistant.components.watergate +watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud weatherflow4py==1.0.6 @@ -2403,7 +2419,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.20.2 +wled==0.21.0 # homeassistant.components.wolflink wolf-comm==0.0.15 @@ -2412,13 +2428,13 @@ wolf-comm==0.0.15 wyoming==1.5.4 # homeassistant.components.xbox -xbox-webapi==2.0.11 +xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.3.0 +xknx==3.4.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -2436,7 +2452,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.1 +yalexs-ble==2.5.5 # homeassistant.components.august # homeassistant.components.yale @@ -2455,7 +2471,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.18 +yt-dlp[default]==2024.12.13 # homeassistant.components.zamg zamg==0.3.6 @@ -2467,7 +2483,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.41 +zha==0.0.42 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index b263373f11d..dcddf267eb4 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.8.1 +ruff==0.8.3 yamllint==1.35.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 450469096ea..5cc609eec2a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -148,7 +148,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.3 +numpy==2.2.0 pandas~=2.2.3 # Constrain multidict to avoid typing issues @@ -158,9 +158,8 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Required to avoid breaking (#101042). -# v2 has breaking changes (#99218). -pydantic==1.10.19 +# ensure pydantic version does not float since it might have breaking changes +pydantic==2.10.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -238,11 +237,6 @@ 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 - -# 0.22.0 causes CI failures on Python 3.13 -# python3 -X dev -m pytest tests/components/matrix -# python3 -X dev -m pytest tests/components/zha -rpds-py==0.21.0 """ GENERATED_MESSAGE = ( @@ -634,7 +628,6 @@ def _get_hassfest_config() -> Config: specific_integrations=None, action="validate", requirements=True, - core_integrations_path=Path("homeassistant/components"), ) diff --git a/script/hassfest/__init__.py b/script/hassfest/__init__.py index 2fa7997162f..c8c9aa9ef39 100644 --- a/script/hassfest/__init__.py +++ b/script/hassfest/__init__.py @@ -1 +1,14 @@ """Manifest validator.""" + +import ast +from functools import lru_cache +from pathlib import Path + + +@lru_cache +def ast_parse_module(file_path: Path) -> ast.Module: + """Parse a module. + + Cached to avoid parsing the same file for each plugin. + """ + return ast.parse(file_path.read_text()) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 81670de5afd..c93d8fd4499 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -110,10 +110,10 @@ def get_config() -> Config: help="Comma-separate list of plugins to run. Valid plugin names: %(default)s", ) parser.add_argument( - "--core-integrations-path", + "--core-path", type=Path, - default=Path("homeassistant/components"), - help="Path to core integrations", + default=Path(), + help="Path to core", ) parsed = parser.parse_args() @@ -125,16 +125,18 @@ def get_config() -> Config: "Generate is not allowed when limiting to specific integrations" ) - if not parsed.integration_path and not Path("requirements_all.txt").is_file(): + if ( + not parsed.integration_path + and not (parsed.core_path / "requirements_all.txt").is_file() + ): raise RuntimeError("Run from Home Assistant root") return Config( - root=Path().absolute(), + root=parsed.core_path.absolute(), specific_integrations=parsed.integration_path, action=parsed.action, requirements=parsed.requirements, plugins=set(parsed.plugins), - core_integrations_path=parsed.core_integrations_path, ) diff --git a/script/hassfest/config_schema.py b/script/hassfest/config_schema.py index 6b863ab9ecd..70dff1194bc 100644 --- a/script/hassfest/config_schema.py +++ b/script/hassfest/config_schema.py @@ -6,6 +6,7 @@ import ast from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from . import ast_parse_module from .model import Config, Integration CONFIG_SCHEMA_IGNORE = { @@ -60,7 +61,7 @@ def _validate_integration(config: Config, integration: Integration) -> None: # Virtual integrations don't have any implementation return - init = ast.parse(init_file.read_text()) + init = ast_parse_module(init_file) # No YAML Support if not _has_function( @@ -81,7 +82,7 @@ def _validate_integration(config: Config, integration: Integration) -> None: config_file = integration.path / "config.py" if config_file.is_file(): - config_module = ast.parse(config_file.read_text()) + config_module = ast_parse_module(config_file) if _has_function(config_module, ast.AsyncFunctionDef, "async_validate_config"): return diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 0c7f4f11a8c..62644e19c5e 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -10,6 +10,7 @@ from pathlib import Path from homeassistant.const import Platform from homeassistant.requirements import DISCOVERY_INTEGRATIONS +from . import ast_parse_module from .model import Config, Integration @@ -33,7 +34,7 @@ class ImportCollector(ast.NodeVisitor): self._cur_fil_dir = fil.relative_to(self.integration.path) self.referenced[self._cur_fil_dir] = set() try: - self.visit(ast.parse(fil.read_text())) + self.visit(ast_parse_module(fil)) except SyntaxError as e: e.add_note(f"File: {fil}") raise diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 57d86bc4def..022caee30cd 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -185,12 +185,12 @@ def _generate_files(config: Config) -> list[File]: + 10 ) * 1000 - package_versions = _get_package_versions(Path("requirements.txt"), {"uv"}) + package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"}) package_versions |= _get_package_versions( - Path("requirements_test.txt"), {"pipdeptree", "tqdm"} + config.root / "requirements_test.txt", {"pipdeptree", "tqdm"} ) package_versions |= _get_package_versions( - Path("requirements_test_pre_commit.txt"), {"ruff"} + config.root / "requirements_test_pre_commit.txt", {"ruff"} ) return [ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 38b8ba5e8d0..369beb538ed 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.5.4,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ @@ -22,8 +22,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.3 \ + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.9 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/docker/entrypoint.sh b/script/hassfest/docker/entrypoint.sh index 7b75eb186d2..eabc08a9499 100755 --- a/script/hassfest/docker/entrypoint.sh +++ b/script/hassfest/docker/entrypoint.sh @@ -2,16 +2,28 @@ integrations="" integration_path="" +core_path_provided=false -# Enable recursive globbing using find -for manifest in $(find . -name "manifest.json"); do - manifest_path=$(realpath "${manifest}") - integrations="$integrations --integration-path ${manifest_path%/*}" +for arg in "$@"; do + case "$arg" in + --core-path=*) + core_path_provided=true + break + ;; + esac done -if [ -z "$integrations" ]; then - echo "Error: No integrations found!" - exit 1 +if [ "$core_path_provided" = false ]; then + # Enable recursive globbing using find + for manifest in $(find . -name "manifest.json"); do + manifest_path=$(realpath "${manifest}") + integrations="$integrations --integration-path ${manifest_path%/*}" + done + + if [ -z "$integrations" ]; then + echo "Error: No integrations found!" + exit 1 + fi fi cd /usr/src/homeassistant || exit 1 diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 377f82b0d5c..08ded687096 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -30,11 +30,15 @@ class Config: root: pathlib.Path action: Literal["validate", "generate"] requirements: bool - core_integrations_path: pathlib.Path + core_integrations_path: pathlib.Path = field(init=False) errors: list[Error] = field(default_factory=list) cache: dict[str, Any] = field(default_factory=dict) plugins: set[str] = field(default_factory=set) + def __post_init__(self) -> None: + """Post init.""" + self.core_integrations_path = self.root / "homeassistant/components" + def add_error(self, *args: Any, **kwargs: Any) -> None: """Add an error.""" self.errors.append(Error(*args, **kwargs)) diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 25fe875e437..1d7f2b5ed88 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -33,7 +33,12 @@ HEADER: Final = """ GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), "platform": "linux", - "plugins": "pydantic.mypy", + "plugins": ", ".join( # noqa: FLY002 + [ + "pydantic.mypy", + "pydantic.v1.mypy", + ] + ), "show_error_codes": "true", "follow_imports": "normal", # "enable_incomplete_feature": ", ".join( # noqa: FLY002 @@ -42,14 +47,15 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { # Enable some checks globally. "local_partial_types": "true", "strict_equality": "true", + "strict_bytes": "true", "no_implicit_optional": "true", - "report_deprecated_as_error": "true", "warn_incomplete_stub": "true", "warn_redundant_casts": "true", "warn_unused_configs": "true", "warn_unused_ignores": "true", "enable_error_code": ", ".join( # noqa: FLY002 [ + "deprecated", "ignore-without-code", "redundant-self", "truthy-iterable", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4f3c7ea7cbc..5ad3467dd79 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -18,10 +18,12 @@ from .quality_scale_validation import ( config_flow, diagnostics, discovery, + parallel_updates, reauthentication_flow, reconfiguration_flow, runtime_data, strict_typing, + test_before_setup, unique_config_entry, ) @@ -55,7 +57,7 @@ ALL_RULES = [ Rule("has-entity-name", ScaledQualityScaleTiers.BRONZE), Rule("runtime-data", ScaledQualityScaleTiers.BRONZE, runtime_data), Rule("test-before-configure", ScaledQualityScaleTiers.BRONZE), - Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE), + Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE, test_before_setup), Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE, unique_config_entry), # SILVER Rule("action-exceptions", ScaledQualityScaleTiers.SILVER), @@ -67,7 +69,7 @@ ALL_RULES = [ Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER), Rule("integration-owner", ScaledQualityScaleTiers.SILVER), Rule("log-when-unavailable", ScaledQualityScaleTiers.SILVER), - Rule("parallel-updates", ScaledQualityScaleTiers.SILVER), + Rule("parallel-updates", ScaledQualityScaleTiers.SILVER, parallel_updates), Rule( "reauthentication-flow", ScaledQualityScaleTiers.SILVER, reauthentication_flow ), @@ -221,7 +223,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bond", "bosch_shc", "braviatv", - "bring", "broadlink", "brother", "brottsplatskartan", @@ -234,7 +235,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bthome", "buienradar", "caldav", - "cambridge_audio", "canary", "cast", "ccm15", @@ -312,7 +312,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "drop_connect", "dsmr", "dsmr_reader", - "dte_energy_bridge", "dublin_bus_transport", "duckdns", "duke_energy", @@ -339,7 +338,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "eight_sleep", "electrasmart", "electric_kiwi", - "elevenlabs", "eliqonline", "elkm1", "elmax", @@ -391,7 +389,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "fints", "fireservicerota", "firmata", - "fitbit", "fivem", "fixer", "fjaraskupan", @@ -425,7 +422,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "fujitsu_fglair", "fujitsu_hvac", "futurenow", - "fyta", "garadget", "garages_amsterdam", "gardena_bluetooth", @@ -458,10 +454,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "google_generative_ai_conversation", "google_mail", "google_maps", - "google_photos", "google_pubsub", "google_sheets", - "google_tasks", "google_translate", "google_travel_time", "google_wifi", @@ -478,7 +472,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "gstreamer", "gtfs", "guardian", - "habitica", "harman_kardon_avr", "harmony", "hassio", @@ -486,7 +479,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "hddtemp", "hdmi_cec", "heatmiser", - "heos", "here_travel_time", "hikvision", "hikvisioncam", @@ -512,7 +504,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "hue", "huisbaasje", "hunterdouglas_powerview", - "husqvarna_automower", "husqvarna_automower_ble", "huum", "hvv_departures", @@ -523,7 +514,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iaqualink", "ibeacon", "icloud", - "idasen_desk", "idteck_prox", "ifttt", "iglo", @@ -546,13 +536,11 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ipp", "iqvia", "irish_rail_transport", - "iron_os", "isal", "iskra", "islamic_prayer_times", "israel_rail", "iss", - "ista_ecotrend", "isy994", "itach", "itunes", @@ -587,7 +575,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "kwb", "lacrosse", "lacrosse_view", - "lametric", "landisgyr_heat_meter", "lannouncer", "lastfm", @@ -643,7 +630,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "manual_mqtt", "map", "marytts", - "mastodon", "matrix", "matter", "maxcube", @@ -708,7 +694,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "mysensors", "mystrom", "mythicbeastsdns", - "myuplink", "nad", "nam", "namecheapdns", @@ -717,7 +702,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "neato", "nederlandse_spoorwegen", "ness_alarm", - "nest", "netatmo", "netdata", "netgear", @@ -742,7 +726,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "no_ip", "noaa_tides", "nobo_hub", - "nordpool", "norway_air", "notify_events", "notion", @@ -767,7 +750,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "oncue", "ondilo_ico", "onewire", - "onkyo", "onvif", "open_meteo", "openai_conversation", @@ -902,7 +884,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rtorrent", "rtsp_to_webrtc", "ruckus_unleashed", - "russound_rio", "russound_rnet", "ruuvi_gateway", "ruuvitag_ble", @@ -999,11 +980,9 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "steam_online", "steamist", "stiebel_eltron", - "stookalert", "stream", "streamlabswater", "subaru", - "suez_water", "sun", "sunweg", "supervisord", @@ -1073,7 +1052,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "tomorrowio", "toon", "torque", - "totalconnect", "touchline", "touchline_sl", "tplink", @@ -1120,7 +1098,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "v2c", "vallox", "vasttrafik", - "velbus", "velux", "venstar", "vera", @@ -1361,16 +1338,19 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: "quality_scale", f"Invalid {name}: {humanize_error(data, err)}" ) + rules_done = set[str]() rules_met = set[str]() for rule_name, rule_value in data.get("rules", {}).items(): status = rule_value["status"] if isinstance(rule_value, dict) else rule_value if status not in {"done", "exempt"}: continue rules_met.add(rule_name) - if ( - status == "done" - and (validator := VALIDATORS.get(rule_name)) - and (errors := validator.validate(integration)) + if status == "done": + rules_done.add(rule_name) + + for rule_name in rules_done: + if (validator := VALIDATORS.get(rule_name)) and ( + errors := validator.validate(config, integration, rules_done=rules_done) ): for error in errors: integration.add_error("quality_scale", f"[{rule_name}] {error}") diff --git a/script/hassfest/quality_scale_validation/__init__.py b/script/hassfest/quality_scale_validation/__init__.py index 836c1082763..7c41a58b601 100644 --- a/script/hassfest/quality_scale_validation/__init__.py +++ b/script/hassfest/quality_scale_validation/__init__.py @@ -2,13 +2,15 @@ from typing import Protocol -from script.hassfest.model import Integration +from script.hassfest.model import Config, Integration class RuleValidationProtocol(Protocol): """Protocol for rule validation.""" - def validate(self, integration: Integration) -> list[str] | None: + def validate( + self, config: Config, integration: Integration, *, rules_done: set[str] + ) -> list[str] | None: """Validate a quality scale rule. Returns error (if any). diff --git a/script/hassfest/quality_scale_validation/config_entry_unloading.py b/script/hassfest/quality_scale_validation/config_entry_unloading.py index 50f42752bf6..4874ddc4625 100644 --- a/script/hassfest/quality_scale_validation/config_entry_unloading.py +++ b/script/hassfest/quality_scale_validation/config_entry_unloading.py @@ -5,7 +5,8 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/c import ast -from script.hassfest.model import Integration +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration def _has_unload_entry_function(module: ast.Module) -> bool: @@ -16,11 +17,13 @@ def _has_unload_entry_function(module: ast.Module) -> bool: ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration has a config flow.""" init_file = integration.path / "__init__.py" - init = ast.parse(init_file.read_text()) + init = ast_parse_module(init_file) if not _has_unload_entry_function(init): return [ diff --git a/script/hassfest/quality_scale_validation/config_flow.py b/script/hassfest/quality_scale_validation/config_flow.py index e1361d6550f..d1ac70ab469 100644 --- a/script/hassfest/quality_scale_validation/config_flow.py +++ b/script/hassfest/quality_scale_validation/config_flow.py @@ -3,10 +3,12 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow/ """ -from script.hassfest.model import Integration +from script.hassfest.model import Config, Integration -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration implements config flow.""" if not integration.config_flow: diff --git a/script/hassfest/quality_scale_validation/diagnostics.py b/script/hassfest/quality_scale_validation/diagnostics.py index 99f067d6500..ea143002b09 100644 --- a/script/hassfest/quality_scale_validation/diagnostics.py +++ b/script/hassfest/quality_scale_validation/diagnostics.py @@ -5,7 +5,8 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/d import ast -from script.hassfest.model import Integration +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration DIAGNOSTICS_FUNCTIONS = { "async_get_config_entry_diagnostics", @@ -21,7 +22,9 @@ def _has_diagnostics_function(module: ast.Module) -> bool: ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration implements diagnostics.""" diagnostics_file = integration.path / "diagnostics.py" @@ -31,7 +34,7 @@ def validate(integration: Integration) -> list[str] | None: "(is missing diagnostics.py)", ] - diagnostics = ast.parse(diagnostics_file.read_text()) + diagnostics = ast_parse_module(diagnostics_file) if not _has_diagnostics_function(diagnostics): return [ diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py index a4f01ce0269..d11bcaf2cec 100644 --- a/script/hassfest/quality_scale_validation/discovery.py +++ b/script/hassfest/quality_scale_validation/discovery.py @@ -5,41 +5,57 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/d import ast -from script.hassfest.model import Integration +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration -DISCOVERY_FUNCTIONS = [ - "async_step_discovery", +MANIFEST_KEYS = [ + "bluetooth", + "dhcp", + "homekit", + "mqtt", + "ssdp", + "usb", + "zeroconf", +] +CONFIG_FLOW_STEPS = { "async_step_bluetooth", + "async_step_discovery", + "async_step_dhcp", "async_step_hassio", "async_step_homekit", "async_step_mqtt", "async_step_ssdp", - "async_step_zeroconf", - "async_step_dhcp", "async_step_usb", -] + "async_step_zeroconf", +} def _has_discovery_function(module: ast.Module) -> bool: """Test if the module defines at least one of the discovery functions.""" return any( - type(item) is ast.AsyncFunctionDef and item.name in DISCOVERY_FUNCTIONS + type(item) is ast.AsyncFunctionDef and item.name in CONFIG_FLOW_STEPS for item in ast.walk(module) ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration implements diagnostics.""" config_flow_file = integration.path / "config_flow.py" if not config_flow_file.exists(): return ["Integration is missing config_flow.py"] - config_flow = ast.parse(config_flow_file.read_text()) + # Check manifest + if any(key in integration.manifest for key in MANIFEST_KEYS): + return None - if not _has_discovery_function(config_flow): + # Fallback => check config_flow step + config_flow = ast_parse_module(config_flow_file) + if not (_has_discovery_function(config_flow)): return [ - f"Integration is missing one of {DISCOVERY_FUNCTIONS} " + f"Integration is missing one of {CONFIG_FLOW_STEPS} " f"in {config_flow_file}" ] diff --git a/script/hassfest/quality_scale_validation/parallel_updates.py b/script/hassfest/quality_scale_validation/parallel_updates.py new file mode 100644 index 00000000000..00ad891774d --- /dev/null +++ b/script/hassfest/quality_scale_validation/parallel_updates.py @@ -0,0 +1,38 @@ +"""Enforce that the integration sets PARALLEL_UPDATES constant. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/parallel-updates +""" + +import ast + +from homeassistant.const import Platform +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + + +def _has_parallel_updates_defined(module: ast.Module) -> bool: + """Test if the module defines `PARALLEL_UPDATES` constant.""" + return any( + type(item) is ast.Assign and item.targets[0].id == "PARALLEL_UPDATES" + for item in module.body + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration sets PARALLEL_UPDATES constant.""" + + errors = [] + for platform in Platform: + module_file = integration.path / f"{platform}.py" + if not module_file.exists(): + continue + module = ast_parse_module(module_file) + + if not _has_parallel_updates_defined(module): + errors.append( + f"Integration does not set `PARALLEL_UPDATES` in {module_file}" + ) + + return errors diff --git a/script/hassfest/quality_scale_validation/reauthentication_flow.py b/script/hassfest/quality_scale_validation/reauthentication_flow.py index 311f8a2429d..3db9700af98 100644 --- a/script/hassfest/quality_scale_validation/reauthentication_flow.py +++ b/script/hassfest/quality_scale_validation/reauthentication_flow.py @@ -5,7 +5,8 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/r import ast -from script.hassfest.model import Integration +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration def _has_step_reauth_function(module: ast.Module) -> bool: @@ -16,11 +17,13 @@ def _has_step_reauth_function(module: ast.Module) -> bool: ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration has a reauthentication flow.""" config_flow_file = integration.path / "config_flow.py" - config_flow = ast.parse(config_flow_file.read_text()) + config_flow = ast_parse_module(config_flow_file) if not _has_step_reauth_function(config_flow): return [ diff --git a/script/hassfest/quality_scale_validation/reconfiguration_flow.py b/script/hassfest/quality_scale_validation/reconfiguration_flow.py index de3b5dcba62..28cc0ef6d43 100644 --- a/script/hassfest/quality_scale_validation/reconfiguration_flow.py +++ b/script/hassfest/quality_scale_validation/reconfiguration_flow.py @@ -5,7 +5,8 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/r import ast -from script.hassfest.model import Integration +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration def _has_step_reconfigure_function(module: ast.Module) -> bool: @@ -16,11 +17,13 @@ def _has_step_reconfigure_function(module: ast.Module) -> bool: ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration has a reconfiguration flow.""" config_flow_file = integration.path / "config_flow.py" - config_flow = ast.parse(config_flow_file.read_text()) + config_flow = ast_parse_module(config_flow_file) if not _has_step_reconfigure_function(config_flow): return [ diff --git a/script/hassfest/quality_scale_validation/runtime_data.py b/script/hassfest/quality_scale_validation/runtime_data.py index 765db43d1e3..cfc4c5224de 100644 --- a/script/hassfest/quality_scale_validation/runtime_data.py +++ b/script/hassfest/quality_scale_validation/runtime_data.py @@ -4,8 +4,30 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/r """ import ast +import re -from script.hassfest.model import Integration +from homeassistant.const import Platform +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + +_ANNOTATION_MATCH = re.compile(r"^[A-Za-z]+ConfigEntry$") +_FUNCTIONS: dict[str, dict[str, int]] = { + "__init__": { # based on ComponentProtocol + "async_migrate_entry": 2, + "async_remove_config_entry_device": 2, + "async_remove_entry": 2, + "async_setup_entry": 2, + "async_unload_entry": 2, + }, + "diagnostics": { # based on DiagnosticsProtocol + "async_get_config_entry_diagnostics": 2, + "async_get_device_diagnostics": 2, + }, +} +for platform in Platform: # based on EntityPlatformModule + _FUNCTIONS[platform.value] = { + "async_setup_entry": 2, + } def _sets_runtime_data( @@ -24,30 +46,85 @@ def _sets_runtime_data( return False -def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None: - """Get async_setup_entry function.""" +def _get_async_function(module: ast.Module, name: str) -> ast.AsyncFunctionDef | None: + """Get async function.""" for item in module.body: - if isinstance(item, ast.AsyncFunctionDef) and item.name == "async_setup_entry": + if isinstance(item, ast.AsyncFunctionDef) and item.name == name: return item return None -def validate(integration: Integration) -> list[str] | None: +def _check_function_annotation( + function: ast.AsyncFunctionDef, position: int +) -> str | None: + """Ensure function uses CustomConfigEntry type annotation.""" + if len(function.args.args) < position: + return f"{function.name} has incorrect signature" + argument = function.args.args[position - 1] + if not ( + (annotation := argument.annotation) + and isinstance(annotation, ast.Name) + and _ANNOTATION_MATCH.match(annotation.id) + ): + return f"([+ strict-typing]) {function.name} does not use typed ConfigEntry" + return None + + +def _check_typed_config_entry(integration: Integration) -> list[str]: + """Ensure integration uses CustomConfigEntry type annotation.""" + errors: list[str] = [] + # Check body level function annotations + for file, functions in _FUNCTIONS.items(): + module_file = integration.path / f"{file}.py" + if not module_file.exists(): + continue + module = ast_parse_module(module_file) + for function, position in functions.items(): + if not (async_function := _get_async_function(module, function)): + continue + if error := _check_function_annotation(async_function, position): + errors.append(f"{error} in {module_file}") + + # Check config_flow annotations + config_flow_file = integration.path / "config_flow.py" + config_flow = ast_parse_module(config_flow_file) + for node in config_flow.body: + if not isinstance(node, ast.ClassDef): + continue + if any( + isinstance(async_function, ast.FunctionDef) + and async_function.name == "async_get_options_flow" + and (error := _check_function_annotation(async_function, 1)) + for async_function in node.body + ): + errors.append(f"{error} in {config_flow_file}") + + return errors + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate correct use of ConfigEntry.runtime_data.""" init_file = integration.path / "__init__.py" - init = ast.parse(init_file.read_text()) + init = ast_parse_module(init_file) # Should not happen, but better to be safe - if not (async_setup_entry := _get_setup_entry_function(init)): + if not (async_setup_entry := _get_async_function(init, "async_setup_entry")): return [f"Could not find `async_setup_entry` in {init_file}"] if len(async_setup_entry.args.args) != 2: return [f"async_setup_entry has incorrect signature in {init_file}"] config_entry_argument = async_setup_entry.args.args[1] + errors: list[str] = [] if not _sets_runtime_data(async_setup_entry, config_entry_argument): - return [ + errors.append( "Integration does not set entry.runtime_data in async_setup_entry" f"({init_file})" - ] + ) - return None + # Extra checks, if strict-typing is marked as done + if "strict-typing" in rules_done: + errors.extend(_check_typed_config_entry(integration)) + + return errors diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py index 285746a9eb6..a27ab752cf0 100644 --- a/script/hassfest/quality_scale_validation/strict_typing.py +++ b/script/hassfest/quality_scale_validation/strict_typing.py @@ -7,27 +7,30 @@ from functools import lru_cache from pathlib import Path import re -from script.hassfest.model import Integration +from script.hassfest.model import Config, Integration _STRICT_TYPING_FILE = Path(".strict-typing") _COMPONENT_REGEX = r"homeassistant.components.([^.]+).*" @lru_cache -def _strict_typing_components() -> set[str]: +def _strict_typing_components(strict_typing_file: Path) -> set[str]: return set( { match.group(1) - for line in _STRICT_TYPING_FILE.read_text(encoding="utf-8").splitlines() + for line in strict_typing_file.read_text(encoding="utf-8").splitlines() if (match := re.match(_COMPONENT_REGEX, line)) is not None } ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration has strict typing enabled.""" + strict_typing_file = config.root / _STRICT_TYPING_FILE - if integration.domain not in _strict_typing_components(): + if integration.domain not in _strict_typing_components(strict_typing_file): return [ "Integration does not have strict typing enabled " "(is missing from .strict-typing)" diff --git a/script/hassfest/quality_scale_validation/test_before_setup.py b/script/hassfest/quality_scale_validation/test_before_setup.py new file mode 100644 index 00000000000..5f21a9d2458 --- /dev/null +++ b/script/hassfest/quality_scale_validation/test_before_setup.py @@ -0,0 +1,82 @@ +"""Enforce that the integration raises correctly during initialisation. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + +_VALID_EXCEPTIONS = { + "ConfigEntryNotReady", + "ConfigEntryAuthFailed", + "ConfigEntryError", +} + + +def _get_exception_name(expression: ast.expr) -> str: + """Get the name of the exception being raised.""" + if isinstance(expression, ast.Name): + return expression.id + + if isinstance(expression, ast.Call): + return _get_exception_name(expression.func) + + if isinstance(expression, ast.Attribute): + return _get_exception_name(expression.value) + + raise AssertionError( + f"Raise is neither Attribute nor Call nor Name: {type(expression)}" + ) + + +def _raises_exception(integration: Integration) -> bool: + """Check that a valid exception is raised.""" + for module_file in integration.path.rglob("*.py"): + module = ast_parse_module(module_file) + for node in ast.walk(module): + if ( + isinstance(node, ast.Raise) + and _get_exception_name(node.exc) in _VALID_EXCEPTIONS + ): + return True + + return False + + +def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool: + """Check that a async_config_entry_first_refresh within `async_setup_entry`.""" + for node in ast.walk(async_setup_entry_function): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "async_config_entry_first_refresh" + ): + return True + + return False + + +def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None: + """Get async_setup_entry function.""" + for item in module.body: + if isinstance(item, ast.AsyncFunctionDef) and item.name == "async_setup_entry": + return item + return None + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate correct use of ConfigEntry.runtime_data.""" + init_file = integration.path / "__init__.py" + init = ast_parse_module(init_file) + + # Should not happen, but better to be safe + if not (async_setup_entry := _get_setup_entry_function(init)): + return [f"Could not find `async_setup_entry` in {init_file}"] + + if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)): + return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"] + return None diff --git a/script/hassfest/quality_scale_validation/unique_config_entry.py b/script/hassfest/quality_scale_validation/unique_config_entry.py index eaa879bb05e..83b3d20bd80 100644 --- a/script/hassfest/quality_scale_validation/unique_config_entry.py +++ b/script/hassfest/quality_scale_validation/unique_config_entry.py @@ -5,7 +5,8 @@ https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/u import ast -from script.hassfest.model import Integration +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration def _has_method_call(module: ast.Module, name: str) -> bool: @@ -29,14 +30,16 @@ def _has_abort_unique_id_configured(module: ast.Module) -> bool: ) -def validate(integration: Integration) -> list[str] | None: +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: """Validate that the integration prevents duplicate devices.""" if integration.manifest.get("single_config_entry"): return None config_flow_file = integration.path / "config_flow.py" - config_flow = ast.parse(config_flow_file.read_text()) + config_flow = ast_parse_module(config_flow_file) if not ( _has_abort_entries_match(config_flow) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2fb70b6e0be..078c649666d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -285,6 +285,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: "user" if integration.integration_type == "helper" else None ), ), + vol.Optional("config_subentries"): cv.schema_with_slug_keys( + gen_data_entry_schema( + config=config, + integration=integration, + flow_title=REQUIRED, + require_step_title=False, + ), + slug_validator=vol.Any("_", cv.slug), + ), vol.Optional("options"): gen_data_entry_schema( config=config, integration=integration, diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 45dbed790e6..93c787df50f 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -28,7 +28,7 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() -def main(): +def main() -> int: """Scaffold an integration.""" if not Path("requirements_all.txt").is_file(): print("Run from project root") diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 0bee69b93f8..9ca5ead5719 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -19,7 +19,7 @@ def generate(template: str, info: Info) -> None: print() -def _generate(src_dir, target_dir, info: Info) -> None: +def _generate(src_dir: Path, target_dir: Path, info: Info) -> None: """Generate an integration.""" replaces = {"NEW_DOMAIN": info.domain, "NEW_NAME": info.name} diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 0bff976f288..06db7592840 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for NEW_NAME integration.""" +"""Config flow for the NEW_NAME integration.""" from __future__ import annotations diff --git a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py index e2cfed40e1d..570b70b85aa 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py +++ b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for NEW_NAME.""" +"""Config flow for the NEW_NAME integration.""" import my_pypi_dependency diff --git a/script/scaffold/templates/config_flow_helper/integration/config_flow.py b/script/scaffold/templates/config_flow_helper/integration/config_flow.py index 5d89fec2da2..c2ab7a205da 100644 --- a/script/scaffold/templates/config_flow_helper/integration/config_flow.py +++ b/script/scaffold/templates/config_flow_helper/integration/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for NEW_NAME integration.""" +"""Config flow for the NEW_NAME integration.""" from __future__ import annotations diff --git a/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py b/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py index 51ef70b1885..0f01c8402df 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py @@ -1,11 +1,9 @@ -"""application_credentials platform the NEW_NAME integration.""" +"""Application credentials platform for the NEW_NAME integration.""" from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -# TODO Update with your own urls -OAUTH2_AUTHORIZE = "https://www.example.com/auth/authorize" -OAUTH2_TOKEN = "https://www.example.com/auth/token" +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: diff --git a/script/scaffold/templates/integration/integration/quality_scale.yaml b/script/scaffold/templates/integration/integration/quality_scale.yaml new file mode 100644 index 00000000000..201a91652e5 --- /dev/null +++ b/script/scaffold/templates/integration/integration/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: todo + appropriate-polling: todo + brands: todo + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: todo + docs-actions: todo + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/tests/common.py b/tests/common.py index ac6f10b8c44..d2b0dff8faa 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1000,6 +1000,7 @@ class MockConfigEntry(config_entries.ConfigEntry): reason=None, source=config_entries.SOURCE_USER, state=None, + subentries_data=None, title="Mock Title", unique_id=None, version=1, @@ -1016,6 +1017,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, + "subentries_data": subentries_data or (), "title": title, "unique_id": unique_id, "version": version, diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index d556a20fa90..4be94a09ee8 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -6,7 +6,7 @@ from homeassistant.components.abode import ATTR_DEVICE_ID from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, @@ -46,7 +46,7 @@ async def test_attributes(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert state.attributes.get(ATTR_BRIGHTNESS) == 204 assert state.attributes.get(ATTR_RGB_COLOR) == (0, 64, 255) - assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a" assert not state.attributes.get("battery_low") assert not state.attributes.get("no_response") diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py index 318e881ef2f..4d8ae091dc5 100644 --- a/tests/components/adguard/__init__.py +++ b/tests/components/adguard/__init__.py @@ -1 +1 @@ -"""Tests for the AdGuard Home component.""" +"""Tests for the AdGuard Home integration.""" diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 6644a4ca20f..bd0f1b0a08f 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -59,9 +59,9 @@ async def test_connection_error( ) assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} async def test_full_flow_implementation( @@ -83,25 +83,27 @@ async def test_full_flow_implementation( ) assert result - assert result.get("flow_id") - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["flow_id"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=FIXTURE_USER_INPUT ) - assert result2 - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == FIXTURE_USER_INPUT[CONF_HOST] + assert result + assert result["type"] is FlowResultType.CREATE_ENTRY - data = result2.get("data") - assert data - assert data[CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST] - assert data[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] - assert data[CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT] - assert data[CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL] - assert data[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert data[CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL] + config_entry = result["result"] + assert config_entry.title == FIXTURE_USER_INPUT[CONF_HOST] + assert config_entry.data == { + CONF_HOST: FIXTURE_USER_INPUT[CONF_HOST], + CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], + CONF_PORT: FIXTURE_USER_INPUT[CONF_PORT], + CONF_SSL: FIXTURE_USER_INPUT[CONF_SSL], + CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME], + CONF_VERIFY_SSL: FIXTURE_USER_INPUT[CONF_VERIFY_SSL], + } + assert not config_entry.options async def test_integration_already_exists(hass: HomeAssistant) -> None: @@ -116,8 +118,8 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_USER}, ) assert result - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_hassio_already_configured(hass: HomeAssistant) -> None: @@ -141,8 +143,8 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_HASSIO}, ) assert result - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_hassio_ignored(hass: HomeAssistant) -> None: @@ -166,8 +168,8 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_HASSIO}, ) assert result - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_hassio_confirm( @@ -195,24 +197,25 @@ async def test_hassio_confirm( context={"source": config_entries.SOURCE_HASSIO}, ) assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert result.get("description_placeholders") == {"addon": "AdGuard Home Addon"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "AdGuard Home Addon"} - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result2 - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "AdGuard Home Addon" + assert result + assert result["type"] is FlowResultType.CREATE_ENTRY - data = result2.get("data") - assert data - assert data[CONF_HOST] == "mock-adguard" - assert data[CONF_PASSWORD] is None - assert data[CONF_PORT] == 3000 - assert data[CONF_SSL] is False - assert data[CONF_USERNAME] is None - assert data[CONF_VERIFY_SSL] + config_entry = result["result"] + assert config_entry.title == "AdGuard Home Addon" + assert config_entry.data == { + CONF_HOST: "mock-adguard", + CONF_PASSWORD: None, + CONF_PORT: 3000, + CONF_SSL: False, + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + } async def test_hassio_connection_error( @@ -241,6 +244,6 @@ async def test_hassio_connection_error( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 54546507dfa..1e09a372352 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index ec501b2fd7e..1c760eaec52 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 3dd4788dc61..73ba6a7123f 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -35,6 +35,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index 606d6082351..0dbdef1d508 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index cb1d3a7aee7..113db6e3b96 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'XXXXXXX', 'version': 1, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e..39668e3d19f 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -287,6 +287,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index c6ad36916bf..4bd7bfaccdd 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'installation1', 'version': 1, diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 84d27a96db2..168d7ecc269 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -54,29 +54,6 @@ async def help_test_async_alarm_control_panel_service( await hass.async_block_till_done() -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity): - _attr_supported_features = 1 - - entity = MockAlarmControlPanelEntity() - assert ( - entity.supported_features - is alarm_control_panel.AlarmControlPanelEntityFeature(1) - ) - assert "MockAlarmControlPanelEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text - caplog.clear() - assert ( - entity.supported_features - is alarm_control_panel.AlarmControlPanelEntityFeature(1) - ) - assert "is using deprecated supported features values" not in caplog.text - - async def test_set_mock_alarm_control_panel_options( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index a41c2f47b2d..b10a93df0c9 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -159,11 +159,11 @@ async def test_api_set_color_temperature(hass: HomeAssistant) -> None: assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["kelvin"] == 7500 + assert call_light[0].data["color_temp_kelvin"] == 7500 assert msg["header"]["name"] == "Response" -@pytest.mark.parametrize(("result", "initial"), [(383, "333"), (500, "500")]) +@pytest.mark.parametrize(("result", "initial"), [(2500, "3000"), (2000, "2000")]) async def test_api_decrease_color_temp( hass: HomeAssistant, result: int, initial: str ) -> None: @@ -176,7 +176,11 @@ async def test_api_decrease_color_temp( hass.states.async_set( "light.test", "off", - {"friendly_name": "Test light", "color_temp": initial, "max_mireds": 500}, + { + "friendly_name": "Test light", + "color_temp_kelvin": initial, + "min_color_temp_kelvin": 2000, + }, ) call_light = async_mock_service(hass, "light", "turn_on") @@ -189,11 +193,11 @@ async def test_api_decrease_color_temp( assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["color_temp"] == result + assert call_light[0].data["color_temp_kelvin"] == result assert msg["header"]["name"] == "Response" -@pytest.mark.parametrize(("result", "initial"), [(283, "333"), (142, "142")]) +@pytest.mark.parametrize(("result", "initial"), [(3500, "3000"), (7000, "7000")]) async def test_api_increase_color_temp( hass: HomeAssistant, result: int, initial: str ) -> None: @@ -206,7 +210,11 @@ async def test_api_increase_color_temp( hass.states.async_set( "light.test", "off", - {"friendly_name": "Test light", "color_temp": initial, "min_mireds": 142}, + { + "friendly_name": "Test light", + "color_temp_kelvin": initial, + "max_color_temp_kelvin": 7000, + }, ) call_light = async_mock_service(hass, "light", "turn_on") @@ -219,7 +227,7 @@ async def test_api_increase_color_temp( assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["color_temp"] == result + assert call_light[0].data["color_temp_kelvin"] == result assert msg["header"]["name"] == "Response" diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 2f90b09d39f..07db19101ab 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/apsystems/test_init.py b/tests/components/apsystems/test_init.py index c85c4094e30..f127744dbf4 100644 --- a/tests/components/apsystems/test_init.py +++ b/tests/components/apsystems/test_init.py @@ -1,8 +1,11 @@ """Test the APSystem setup.""" +import datetime from unittest.mock import AsyncMock from APsystemsEZ1 import InverterReturnedError +from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.apsystems.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -10,16 +13,57 @@ from homeassistant.core import HomeAssistant from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + +SCAN_INTERVAL = datetime.timedelta(seconds=12) -async def test_update_failed( +@pytest.mark.usefixtures("mock_apsystems") +async def test_load_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_failed( hass: HomeAssistant, mock_apsystems: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test update failed.""" - mock_apsystems.get_output_data.side_effect = InverterReturnedError + mock_apsystems.get_device_info.side_effect = TimeoutError await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_update( + hass: HomeAssistant, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update data with an inverter error and recover.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert "Inverter returned an error" not in caplog.text + mock_apsystems.get_output_data.side_effect = InverterReturnedError + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "Error fetching APSystems Data data:" in caplog.text + caplog.clear() + mock_apsystems.get_output_data.side_effect = None + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "Fetching APSystems Data data recovered" in caplog.text diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index c70d3944f88..f63a28efbb7 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -37,6 +37,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }), 'type': , }), @@ -60,6 +61,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -126,6 +128,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en-US', + 'prefer_local_intents': False, }), 'type': , }), @@ -139,7 +142,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en', + 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -149,6 +152,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -215,6 +219,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en-US', + 'prefer_local_intents': False, }), 'type': , }), @@ -228,7 +233,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en', + 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -238,6 +243,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -328,6 +334,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }), 'type': , }), @@ -351,6 +358,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -379,6 +387,93 @@ }), ]) # --- +# name: test_pipeline_from_audio_stream_with_cloud_auth_fail + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'stt.mock_stt', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'language': 'en-US', + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'cloud-auth-failed', + 'message': 'Home Assistant Cloud authentication failed', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_pipeline_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- # name: test_wake_word_detection_aborted list([ dict({ diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 566fb129959..41747a50eb6 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -36,6 +36,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline.4 @@ -58,6 +59,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline.5 @@ -117,6 +119,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_debug.4 @@ -139,6 +142,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_debug.5 @@ -210,6 +214,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_with_enhancements.4 @@ -232,6 +237,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_with_enhancements.5 @@ -313,6 +319,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.6 @@ -335,6 +342,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.7 @@ -519,6 +527,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_intent_failed.2 @@ -541,6 +550,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_intent_timeout.2 @@ -569,6 +579,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'never mind', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_pipeline_empty_tts_output.2 @@ -592,6 +603,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_pipeline_empty_tts_output.3 @@ -680,6 +692,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_text_only_pipeline[extra_msg0].2 @@ -702,6 +715,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_text_only_pipeline[extra_msg0].3 @@ -724,6 +738,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_text_only_pipeline[extra_msg1].2 @@ -746,6 +761,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_text_only_pipeline[extra_msg1].3 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index b177530219e..d4cce4e2e98 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -8,6 +8,7 @@ import tempfile from unittest.mock import ANY, patch import wave +import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion @@ -23,6 +24,7 @@ from homeassistant.components.assist_pipeline.const import ( CONF_DEBUG_RECORDING_DIR, DOMAIN, ) +from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -1098,3 +1100,117 @@ async def test_prefer_local_intents( ] == "Order confirmed" ) + + +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 when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # Pipeline language (en) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.language + ) + + +async def test_pipeline_from_audio_stream_with_cloud_auth_fail( + hass: HomeAssistant, + mock_stt_provider_entity: MockSTTProviderEntity, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream but the cloud authentication fails.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield b"audio" + + with patch.object( + mock_stt_provider_entity, + "async_process_audio_stream", + side_effect=hass_nabucasa.auth.Unauthenticated, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) + + assert process_events(events) == snapshot + assert len(events) == 4 # run start, stt start, error, run end + assert events[2].type == assist_pipeline.PipelineEventType.ERROR + assert events[2].data["code"] == "cloud-auth-failed" diff --git a/tests/components/autarco/test_init.py b/tests/components/autarco/test_init.py index 2707c53d35f..6c71eca5ef1 100644 --- a/tests/components/autarco/test_init.py +++ b/tests/components/autarco/test_init.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from autarco import AutarcoAuthenticationError +from autarco import AutarcoAuthenticationError, AutarcoConnectionError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -30,6 +30,21 @@ async def test_load_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_autarco_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Autarco configuration entry not ready.""" + mock_autarco_client.get_account.side_effect = AutarcoConnectionError + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_setup_entry_exception( hass: HomeAssistant, mock_autarco_client: AsyncMock, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index ebd0061f416..b475c796d2b 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py index 1b0550b147b..5ffc6106c11 100644 --- a/tests/components/azure_event_hub/test_init.py +++ b/tests/components/azure_event_hub/test_init.py @@ -112,6 +112,7 @@ async def test_send_batch_error( ) await hass.async_block_till_done() mock_send_batch.assert_called_once() + mock_send_batch.side_effect = None # Reset to avoid error in teardown async def test_late_event( diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 70b33d2de3f..b06b8a5ef5d 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,29 +2,164 @@ from __future__ import annotations +from collections.abc import AsyncIterator, Callable, Coroutine from pathlib import Path -from unittest.mock import patch +from typing import Any +from unittest.mock import AsyncMock, Mock, patch -from homeassistant.components.backup import DOMAIN -from homeassistant.components.backup.manager import Backup +from homeassistant.components.backup import ( + DOMAIN, + AddonInfo, + AgentBackup, + BackupAgent, + BackupAgentPlatformProtocol, + Folder, +) +from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -TEST_BACKUP = Backup( - slug="abc123", - name="Test", +from tests.common import MockPlatform, mock_platform + +LOCAL_AGENT_ID = f"{DOMAIN}.local" + +TEST_BACKUP_ABC123 = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, date="1970-01-01T00:00:00.000Z", - path=Path("abc123.tar"), - size=0.0, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, ) +TEST_BACKUP_PATH_ABC123 = Path("abc123.tar") + +TEST_BACKUP_DEF456 = AgentBackup( + addons=[], + backup_id="def456", + database_included=False, + date="1980-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + protected=False, + size=1, +) + +TEST_DOMAIN = "test" + + +class BackupAgentTest(BackupAgent): + """Test backup agent.""" + + domain = "test" + + def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: + """Initialize the backup agent.""" + self.name = name + if backups is None: + backups = [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, + date="1970-01-01T00:00:00Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=13, + ) + ] + + self._backup_data: bytearray | None = None + self._backups = {backup.backup_id: backup for backup in backups} + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + return AsyncMock(spec_set=["__aiter__"]) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + self._backups[backup.backup_id] = backup + backup_stream = await open_stream() + self._backup_data = bytearray() + async for chunk in backup_stream: + self._backup_data += chunk + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return list(self._backups.values()) + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return self._backups.get(backup_id) + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, configuration: ConfigType | None = None, + *, + backups: dict[str, list[AgentBackup]] | None = None, + remote_agents: list[str] | None = None, ) -> bool: """Set up the Backup integration.""" - with patch("homeassistant.components.backup.is_hassio", return_value=with_hassio): - return await async_setup_component(hass, DOMAIN, configuration or {}) + with ( + patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), + patch( + "homeassistant.components.backup.backup.is_hassio", return_value=with_hassio + ), + ): + remote_agents = remote_agents or [] + platform = Mock( + async_get_backup_agents=AsyncMock( + return_value=[BackupAgentTest(agent, []) for agent in remote_agents] + ), + spec_set=BackupAgentPlatformProtocol, + ) + + mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) + assert await async_setup_component(hass, TEST_DOMAIN, {}) + + result = await async_setup_component(hass, DOMAIN, configuration or {}) + await hass.async_block_till_done() + if not backups: + return result + + for agent_id, agent_backups in backups.items(): + if with_hassio and agent_id == LOCAL_AGENT_ID: + continue + agent = hass.data[DATA_MANAGER].backup_agents[agent_id] + agent._backups = {backups.backup_id: backups for backups in agent_backups} + if agent_id == LOCAL_AGENT_ID: + agent._loaded_backups = True + + return result diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py new file mode 100644 index 00000000000..13f2537db47 --- /dev/null +++ b/tests/components/backup/conftest.py @@ -0,0 +1,115 @@ +"""Test fixtures for the Backup integration.""" + +from __future__ import annotations + +from asyncio import Future +from collections.abc import Generator +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from homeassistant.components.backup.manager import WrittenBackup +from homeassistant.core import HomeAssistant + +from .common import TEST_BACKUP_PATH_ABC123 + + +@pytest.fixture(name="mocked_json_bytes") +def mocked_json_bytes_fixture() -> Generator[Mock]: + """Mock json_bytes.""" + with patch( + "homeassistant.components.backup.manager.json_bytes", + return_value=b"{}", # Empty JSON + ) as mocked_json_bytes: + yield mocked_json_bytes + + +@pytest.fixture(name="mocked_tarfile") +def mocked_tarfile_fixture() -> Generator[Mock]: + """Mock tarfile.""" + with patch( + "homeassistant.components.backup.manager.SecureTarFile" + ) as mocked_tarfile: + yield mocked_tarfile + + +@pytest.fixture(name="path_glob") +def path_glob_fixture() -> Generator[MagicMock]: + """Mock path glob.""" + with patch( + "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + ) as path_glob: + yield path_glob + + +CONFIG_DIR = { + "testing_config": [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + Path("backups"), + Path("tmp_backups"), + Path("home-assistant_v2.db"), + ], + "backups": [ + Path("backups/backup.tar"), + Path("backups/not_backup"), + ], + "tmp_backups": [ + Path("tmp_backups/forgotten_backup.tar"), + Path("tmp_backups/not_backup"), + ], +} +CONFIG_DIR_DIRS = {Path(".storage"), Path("backups"), Path("tmp_backups")} + + +@pytest.fixture(name="create_backup") +def mock_create_backup() -> Generator[AsyncMock]: + """Mock manager create backup.""" + mock_written_backup = MagicMock(spec_set=WrittenBackup) + mock_written_backup.backup.backup_id = "abc123" + mock_written_backup.open_stream = AsyncMock() + mock_written_backup.release_stream = AsyncMock() + fut = Future() + fut.set_result(mock_written_backup) + with patch( + "homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup" + ) as mock_create_backup: + mock_create_backup.return_value = (MagicMock(), fut) + yield mock_create_backup + + +@pytest.fixture(name="mock_backup_generation") +def mock_backup_generation_fixture( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> Generator[None]: + """Mock backup generator.""" + + with ( + patch("pathlib.Path.iterdir", lambda x: CONFIG_DIR.get(x.name, [])), + patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)), + patch("pathlib.Path.is_file", lambda x: x not in CONFIG_DIR_DIRS), + patch("pathlib.Path.is_dir", lambda x: x in CONFIG_DIR_DIRS), + patch( + "pathlib.Path.exists", + lambda x: x + not in ( + Path(hass.config.path("backups")), + Path(hass.config.path("tmp_backups")), + ), + ), + patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), + patch( + "pathlib.Path.mkdir", + MagicMock(), + ), + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + yield diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr new file mode 100644 index 00000000000..b350ff680ee --- /dev/null +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -0,0 +1,206 @@ +# serializer version: 1 +# name: test_delete_backup[found_backups0-True-1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_backup[found_backups1-False-0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_backup[found_backups2-True-0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[None] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[None].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect2] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect2].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect3] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect3].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect4] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect4].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 096df37d704..8bd4e2817b2 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1,4 +1,32 @@ # serializer version: 1 +# name: test_agent_delete_backup + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_info + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + dict({ + 'agent_id': 'domain.test', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_backup_end[with_hassio-hass_access_token] dict({ 'error': dict({ @@ -40,7 +68,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception0] +# name: test_backup_end_exception[exception0] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -51,7 +79,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception1] +# name: test_backup_end_exception[exception1] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -62,7 +90,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception2] +# name: test_backup_end_exception[exception2] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -114,7 +142,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception0] +# name: test_backup_start_exception[exception0] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -125,7 +153,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception1] +# name: test_backup_start_exception[exception1] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -136,7 +164,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception2] +# name: test_backup_start_exception[exception2] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -147,121 +175,2666 @@ 'type': 'result', }) # --- -# name: test_details[with_hassio-with_backup_content] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_details[with_hassio-without_backup_content] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_details[without_hassio-with_backup_content] +# name: test_config_info[None] dict({ 'id': 1, 'result': dict({ - 'backup': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), }), }), 'success': True, 'type': 'result', }) # --- -# name: test_details[without_hassio-without_backup_content] +# name: test_config_info[storage_data1] dict({ 'id': 1, 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': True, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_strategy_backup': '2024-10-26T04:45:00+01:00', + 'last_completed_strategy_backup': '2024-10-26T04:45:00+01:00', + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': '2024-10-27T04:45:00+01:00', + 'last_completed_strategy_backup': '2024-10-26T04:45:00+01:00', + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'mon', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'sat', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command0].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command0].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command10].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command10].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command1].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command2].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'mon', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command2].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'mon', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command3].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command3].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command4].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command5].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command6].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command6].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command7].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command7].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command8].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command8].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update[command9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command9].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'state': 'daily', + }), + }), + }), + 'key': 'backup', + 'minor_version': 1, + 'version': 1, + }) +# --- +# name: test_config_update_errors[command0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command0].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents0-backups0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents0-backups0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents0-backups0].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents1-backups1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents1-backups1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents1-backups1].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents2-backups2] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents2-backups2].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents2-backups2].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents3-backups3] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents3-backups3].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents3-backups3].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents4-backups4] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents4-backups4].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents4-backups4].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data0] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Boom!', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data1] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Boom!', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details[remote_agents0-backups0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), 'backup': None, }), 'success': True, 'type': 'result', }) # --- -# name: test_generate[with_hassio] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_generate[without_hassio] +# name: test_details[remote_agents1-backups1] dict({ 'id': 1, 'result': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'agent_errors': dict({ + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), }), 'success': True, 'type': 'result', }) # --- -# name: test_info[with_hassio] +# name: test_details[remote_agents2-backups2] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details[remote_agents3-backups3] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details[remote_agents4-backups4] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[BackupAgentUnreachableError] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[side_effect0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Boom!', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_info[without_hassio] +# name: test_generate[None] + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[None].2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].3 + dict({ + 'id': 2, + 'result': dict({ + 'backup_job_id': '27f5c632', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[None].4 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'home_assistant', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].5 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'upload_to_agents', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].6 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'completed', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1] + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data1].2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].3 + dict({ + 'id': 2, + 'result': dict({ + 'backup_job_id': '27f5c632', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data1].4 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'home_assistant', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].5 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'upload_to_agents', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].6 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'completed', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2] + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data2].2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].3 + dict({ + 'id': 2, + 'result': dict({ + 'backup_job_id': '27f5c632', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data2].4 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'home_assistant', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].5 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'upload_to_agents', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].6 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'completed', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_info[remote_agents0-remote_backups0] dict({ 'id': 1, 'result': dict({ - 'backing_up': False, + 'agent_errors': dict({ + }), 'backups': list([ dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, }), ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, }), 'success': True, 'type': 'result', }) # --- -# name: test_remove[with_hassio] +# name: test_info[remote_agents1-remote_backups1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info[remote_agents2-remote_backups2] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info[remote_agents3-remote_backups3] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_strategy_settings': False, + }), + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[BackupAgentUnreachableError] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_strategy_settings': False, + }), + ]), + 'last_attempted_strategy_backup': None, + 'last_completed_strategy_backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[side_effect0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Boom!', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_remove[without_hassio] +# name: test_restore_local_agent[backups0] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_restore_local_agent[backups0].1 + 0 +# --- +# name: test_restore_local_agent[backups1] dict({ 'id': 1, 'result': None, @@ -269,18 +2842,24 @@ 'type': 'result', }) # --- -# name: test_restore[with_hassio] +# name: test_restore_local_agent[backups1].1 + 1 +# --- +# name: test_restore_remote_agent[remote_agents0-backups0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_restore[without_hassio] +# name: test_restore_remote_agent[remote_agents0-backups0].1 + 0 +# --- +# name: test_restore_remote_agent[remote_agents1-backups1] dict({ 'id': 1, 'result': None, @@ -288,3 +2867,34 @@ 'type': 'result', }) # --- +# name: test_restore_remote_agent[remote_agents1-backups1].1 + 1 +# --- +# name: test_subscribe_event + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_subscribe_event.1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_subscribe_event.2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py new file mode 100644 index 00000000000..02252ef6fa5 --- /dev/null +++ b/tests/components/backup/test_backup.py @@ -0,0 +1,129 @@ +"""Test the builtin backup platform.""" + +from __future__ import annotations + +from collections.abc import Generator +from io import StringIO +import json +from pathlib import Path +from tarfile import TarError +from unittest.mock import MagicMock, mock_open, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.backup import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(name="read_backup") +def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: + """Mock read backup.""" + with patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ) as read_backup: + yield read_backup + + +@pytest.mark.parametrize( + "side_effect", + [ + None, + OSError("Boom"), + TarError("Boom"), + json.JSONDecodeError("Boom", "test", 1), + KeyError("Boom"), + ], +) +async def test_load_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + read_backup: MagicMock, + side_effect: Exception | None, +) -> None: + """Test load backups.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + read_backup.side_effect = side_effect + + # list agents + await client.send_json_auto_id({"type": "backup/agents/info"}) + assert await client.receive_json() == snapshot + + # load and list backups + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + +async def test_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test upload backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() + open_mock = mock_open() + + with ( + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 1 + assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + + +@pytest.mark.usefixtures("read_backup") +@pytest.mark.parametrize( + ("found_backups", "backup_exists", "unlink_calls"), + [ + ([TEST_BACKUP_PATH_ABC123], True, 1), + ([TEST_BACKUP_PATH_ABC123], False, 0), + (([], True, 0)), + ], +) +async def test_delete_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + path_glob: MagicMock, + found_backups: list[Path], + backup_exists: bool, + unlink_calls: int, +) -> None: + """Test delete backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + path_glob.return_value = found_backups + + with ( + patch("pathlib.Path.exists", return_value=backup_exists), + patch("pathlib.Path.unlink") as unlink, + ): + await client.send_json_auto_id( + {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + ) + assert await client.receive_json() == snapshot + + assert unlink.call_count == unlink_calls diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 76b1f76b55b..c071a0d8386 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -7,27 +7,28 @@ from unittest.mock import patch from aiohttp import web import pytest +from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP, setup_backup_integration +from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration from tests.common import MockUser from tests.typing import ClientSessionGenerator -async def test_downloading_backup( +async def test_downloading_local_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> None: - """Test downloading a backup file.""" + """Test downloading a local backup file.""" await setup_backup_integration(hass) client = await hass_client() with ( patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_BACKUP, + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, ), patch("pathlib.Path.exists", return_value=True), patch( @@ -35,10 +36,29 @@ async def test_downloading_backup( return_value=web.Response(text=""), ), ): - resp = await client.get("/api/backup/download/abc123") + resp = await client.get("/api/backup/download/abc123?agent_id=backup.local") assert resp.status == 200 +async def test_downloading_remote_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a remote backup.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_client() + + with ( + patch.object(BackupAgentTest, "async_download_backup") as download_mock, + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + resp = await client.get("/api/backup/download/abc123?agent_id=domain.test") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + async def test_downloading_backup_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -48,7 +68,7 @@ async def test_downloading_backup_not_found( client = await hass_client() - resp = await client.get("/api/backup/download/abc123") + resp = await client.get("/api/backup/download/abc123?agent_id=backup.local") assert resp.status == 404 @@ -63,7 +83,7 @@ async def test_downloading_as_non_admin( client = await hass_client() - resp = await client.get("/api/backup/download/abc123") + resp = await client.get("/api/backup/download/abc123?agent_id=backup.local") assert resp.status == 401 @@ -80,7 +100,7 @@ async def test_uploading_a_backup_file( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", ) as async_receive_backup_mock: resp = await client.post( - "/api/backup/upload", + "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 @@ -90,7 +110,7 @@ async def test_uploading_a_backup_file( @pytest.mark.parametrize( ("error", "message"), [ - (OSError("Boom!"), "Can't write backup file Boom!"), + (OSError("Boom!"), "Can't write backup file: Boom!"), (asyncio.CancelledError("Boom!"), ""), ], ) @@ -110,7 +130,7 @@ async def test_error_handling_uploading_a_backup_file( side_effect=error, ): resp = await client.post( - "/api/backup/upload", + "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 500 diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index e064939d618..16a49af9647 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -1,15 +1,18 @@ """Tests for the Backup integration.""" +from typing import Any from unittest.mock import patch import pytest -from homeassistant.components.backup.const import DOMAIN +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +@pytest.mark.usefixtures("supervisor_client") async def test_setup_with_hassio( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -20,14 +23,14 @@ async def test_setup_with_hassio( with_hassio=True, configuration={DOMAIN: {}}, ) - assert ( - "The backup integration is not supported on this installation method, please" - " remove it from your configuration" - ) in caplog.text + manager = hass.data[DATA_MANAGER] + assert not manager.backup_agents +@pytest.mark.parametrize("service_data", [None, {}]) async def test_create_service( hass: HomeAssistant, + service_data: dict[str, Any] | None, ) -> None: """Test generate backup.""" await setup_backup_integration(hass) @@ -39,6 +42,15 @@ async def test_create_service( DOMAIN, "create", blocking=True, + service_data=service_data, ) assert generate_backup.called + + +async def test_create_service_with_hassio(hass: HomeAssistant) -> None: + """Test action backup.create does not exist with hassio.""" + await setup_backup_integration(hass, with_hassio=True) + + with pytest.raises(ServiceNotFound): + await hass.services.async_call(DOMAIN, "create", blocking=True) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a3f70267643..302f4e07011 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2,199 +2,529 @@ from __future__ import annotations +import asyncio +from collections.abc import Generator +from io import StringIO +import json from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch +from typing import Any +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch -import aiohttp -from multidict import CIMultiDict, CIMultiDictProxy import pytest -from homeassistant.components.backup import BackupManager -from homeassistant.components.backup.manager import BackupPlatformProtocol +from homeassistant.components.backup import ( + DOMAIN, + AgentBackup, + BackupAgentPlatformProtocol, + BackupManager, + BackupPlatformProtocol, + Folder, + LocalBackupAgent, + backup as local_backup_platform, +) +from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup.manager import ( + BackupManagerState, + CoreBackupReaderWriter, + CreateBackupEvent, + CreateBackupStage, + CreateBackupState, + NewBackup, + WrittenBackup, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP +from .common import ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + BackupAgentTest, +) from tests.common import MockPlatform, mock_platform +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +_EXPECTED_FILES = [ + "test.txt", + ".storage", + "backups", + "backups/not_backup", + "tmp_backups", + "tmp_backups/not_backup", +] +_EXPECTED_FILES_WITH_DATABASE = { + True: [*_EXPECTED_FILES, "home-assistant_v2.db"], + False: _EXPECTED_FILES, +} -async def _mock_backup_generation(manager: BackupManager): - """Mock backup generator.""" - - def _mock_iterdir(path: Path) -> list[Path]: - if not path.name.endswith("testing_config"): - return [] - return [ - Path("test.txt"), - Path(".DS_Store"), - Path(".storage"), - ] - - with ( - patch( - "homeassistant.components.backup.manager.SecureTarFile" - ) as mocked_tarfile, - patch("pathlib.Path.iterdir", _mock_iterdir), - patch("pathlib.Path.stat", MagicMock(st_size=123)), - patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), - patch( - "pathlib.Path.is_dir", - lambda x: x.name == ".storage", - ), - patch( - "pathlib.Path.exists", - lambda x: x != manager.backup_dir, - ), - patch( - "pathlib.Path.is_symlink", - lambda _: False, - ), - patch( - "pathlib.Path.mkdir", - MagicMock(), - ), - patch( - "homeassistant.components.backup.manager.json_bytes", - return_value=b"{}", # Empty JSON - ) as mocked_json_bytes, - patch( - "homeassistant.components.backup.manager.HAVERSION", - "2025.1.0", - ), - ): - await manager.async_create_backup() - - assert mocked_json_bytes.call_count == 1 - backup_json_dict = mocked_json_bytes.call_args[0][0] - assert isinstance(backup_json_dict, dict) - assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} - assert manager.backup_dir.as_posix() in str( - mocked_tarfile.call_args_list[0][0][0] - ) - - -async def _setup_mock_domain( +async def _setup_backup_platform( hass: HomeAssistant, - platform: BackupPlatformProtocol | None = None, + *, + domain: str = "some_domain", + platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None, ) -> None: """Set up a mock domain.""" - mock_platform(hass, "some_domain.backup", platform or MockPlatform()) - assert await async_setup_component(hass, "some_domain", {}) + mock_platform(hass, f"{domain}.backup", platform or MockPlatform()) + assert await async_setup_component(hass, domain, {}) + await hass.async_block_till_done() -async def test_constructor(hass: HomeAssistant) -> None: - """Test BackupManager constructor.""" - manager = BackupManager(hass) - assert manager.backup_dir.as_posix() == hass.config.path("backups") +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield -async def test_load_backups(hass: HomeAssistant) -> None: - """Test loading backups.""" - manager = BackupManager(hass) - with ( - patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), - patch("tarfile.open", return_value=MagicMock()), - patch( - "homeassistant.components.backup.manager.json_loads_object", - return_value={ - "slug": TEST_BACKUP.slug, - "name": TEST_BACKUP.name, - "date": TEST_BACKUP.date, - }, +@pytest.fixture(name="generate_backup_id") +def generate_backup_id_fixture() -> Generator[MagicMock]: + """Mock generate backup id.""" + with patch("homeassistant.components.backup.manager._generate_backup_id") as mock: + mock.return_value = "abc123" + yield mock + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_async_create_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, +) -> None: + """Test create backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + new_backup = NewBackup(backup_job_id="time-123") + backup_task = AsyncMock( + return_value=WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), ), - patch( - "pathlib.Path.stat", - return_value=MagicMock(st_size=TEST_BACKUP.size), - ), - ): - await manager.load_backups() - backups = await manager.async_get_backups() - assert backups == {TEST_BACKUP.slug: TEST_BACKUP} + )() # call it so that it can be awaited + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) -async def test_load_backups_with_exception( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backups with exception.""" - manager = BackupManager(hass) - with ( - patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), - patch("tarfile.open", side_effect=OSError("Test exception")), - ): - await manager.load_backups() - backups = await manager.async_get_backups() - assert f"Unable to read backup {TEST_BACKUP.path}: Test exception" in caplog.text - assert backups == {} - - -async def test_removing_backup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test removing backup.""" - manager = BackupManager(hass) - manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} - manager.loaded_backups = True - - with patch("pathlib.Path.exists", return_value=True): - await manager.async_remove_backup(slug=TEST_BACKUP.slug) - assert "Removed backup located at" in caplog.text - - -async def test_removing_non_existing_backup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test removing not existing backup.""" - manager = BackupManager(hass) - - await manager.async_remove_backup(slug="non_existing") - assert "Removed backup located at" not in caplog.text - - -async def test_getting_backup_that_does_not_exist( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test getting backup that does not exist.""" - manager = BackupManager(hass) - manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} - manager.loaded_backups = True - - with patch("pathlib.Path.exists", return_value=False): - backup = await manager.async_get_backup(slug=TEST_BACKUP.slug) - assert backup is None - - assert ( - f"Removing tracked backup ({TEST_BACKUP.slug}) that " - f"does not exists on the expected path {TEST_BACKUP.path}" - ) in caplog.text + assert create_backup.called + assert create_backup.call_args == call( + agent_ids=["backup.local"], + backup_name="Core 2025.1.0", + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + on_progress=ANY, + password=None, + ) async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: """Test generate backup.""" - manager = BackupManager(hass) - manager.backing_up = True - with pytest.raises(HomeAssistantError, match="Backup already in progress"): - await manager.async_create_backup() + manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + manager.last_event = CreateBackupEvent( + stage=None, state=CreateBackupState.IN_PROGRESS + ) + with pytest.raises(HomeAssistantError, match="Backup manager busy"): + await manager.async_create_backup( + agent_ids=[LOCAL_AGENT_ID], + include_addons=[], + include_all_addons=False, + include_database=True, + include_folders=[], + include_homeassistant=True, + name=None, + password=None, + ) -async def test_async_create_backup( +@pytest.mark.parametrize( + ("parameters", "expected_error"), + [ + ({"agent_ids": []}, "At least one agent must be selected"), + ({"agent_ids": ["non_existing"]}, "Invalid agent selected"), + ( + {"include_addons": ["ssl"], "include_all_addons": True}, + "Cannot include all addons and specify specific addons", + ), + ({"include_homeassistant": False}, "Home Assistant must be included in backup"), + ], +) +async def test_create_backup_wrong_parameters( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test create backup with wrong parameters.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + default_parameters = { + "agent_ids": [LOCAL_AGENT_ID], + "include_addons": [], + "include_all_addons": False, + "include_database": True, + "include_folders": [], + "include_homeassistant": True, + } + + await ws_client.send_json_auto_id( + {"type": "backup/generate"} | default_parameters | parameters + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == expected_error + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + [ + ([LOCAL_AGENT_ID], "backups", 0), + (["test.remote"], "tmp_backups", 1), + ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ], +) +@pytest.mark.parametrize( + "params", + [ + {}, + {"include_database": True, "name": "abc123"}, + {"include_database": False}, + {"password": "pass123"}, + ], +) +async def test_async_initiate_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + generate_backup_id: MagicMock, + path_glob: MagicMock, + params: dict[str, Any], + agent_ids: list[str], + backup_directory: str, + temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" - manager = BackupManager(hass) - manager.loaded_backups = True + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + agents = { + f"backup.{local_agent.name}": local_agent, + f"test.{remote_agent.name}": remote_agent, + } + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) - await _mock_backup_generation(manager) + ws_client = await hass_ws_client(hass) - assert "Generated new backup with slug " in caplog.text - assert "Creating backup directory" in caplog.text - assert "Loaded 0 platforms" in caplog.text + include_database = params.get("include_database", True) + name = params.get("name", "Core 2025.1.0") + password = params.get("password") + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch("pathlib.Path.unlink") as unlink_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} | params + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert unlink_mock.call_count == temp_file_unlink_call_count + + assert mocked_json_bytes.call_count == 1 + backup_json_dict = mocked_json_bytes.call_args[0][0] + assert isinstance(backup_json_dict, dict) + assert backup_json_dict == { + "compressed": True, + "date": ANY, + "homeassistant": { + "exclude_database": not include_database, + "version": "2025.1.0", + }, + "name": name, + "protected": bool(password), + "slug": ANY, + "type": "partial", + "version": 2, + } + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} + ) + result = await ws_client.receive_json() + + backup_data = result["result"]["backup"] + backup_agent_ids = backup_data.pop("agent_ids") + + assert backup_agent_ids == agent_ids + + backup = AgentBackup.from_dict(backup_data) + + assert backup == AgentBackup( + addons=[], + backup_id=ANY, + database_included=include_database, + date=ANY, + folders=[], + homeassistant_included=True, + homeassistant_version="2025.1.0", + name=name, + protected=bool(password), + size=ANY, + ) + for agent_id in agent_ids: + agent = agents[agent_id] + assert len(agent._backups) == 1 + agent_backup = agent._backups[backup.backup_id] + assert agent_backup.backup_id == backup.backup_id + assert agent_backup.date == backup.date + assert agent_backup.name == backup.name + assert agent_backup.protected == backup.protected + assert agent_backup.size == backup.size + + outer_tar = mocked_tarfile.return_value + core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value + expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [ + call(file, arcname=f"data/{file}", recursive=False) + for file in _EXPECTED_FILES_WITH_DATABASE[include_database] + ] + assert core_tar.add.call_args_list == expected_files + + tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) + backup_directory = hass.config.path(backup_directory) + assert tar_file_path == f"{backup_directory}/{backup.backup_id}.tar" + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_async_initiate_backup_with_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + generate_backup_id: MagicMock, + path_glob: MagicMock, + hass_storage: dict[str, Any], +) -> None: + """Test generate backup.""" + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch.object( + remote_agent, "async_upload_backup", side_effect=Exception("Test exception") + ), + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + expected_backup_data = { + "addons": [], + "agent_ids": ["backup.local"], + "backup_id": "abc123", + "database_included": True, + "date": ANY, + "failed_agent_ids": ["test.remote"], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": "Core 2025.1.0", + "protected": False, + "size": 123, + "with_strategy_settings": False, + } + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} + ) + result = await ws_client.receive_json() + assert result["result"] == { + "agent_errors": {}, + "backup": expected_backup_data, + } + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["result"] == { + "agent_errors": {}, + "backups": [expected_backup_data], + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + "with_strategy_settings": False, + } + ] async def test_loading_platforms( @@ -202,198 +532,449 @@ async def test_loading_platforms( caplog: pytest.LogCaptureFixture, ) -> None: """Test loading backup platforms.""" - manager = BackupManager(hass) + manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - assert not manager.loaded_platforms assert not manager.platforms - await _setup_mock_domain( + get_agents_mock = AsyncMock(return_value=[]) + + await _setup_backup_platform( hass, - Mock( + platform=Mock( async_pre_backup=AsyncMock(), async_post_backup=AsyncMock(), + async_get_backup_agents=get_agents_mock, ), ) await manager.load_platforms() await hass.async_block_till_done() - assert manager.loaded_platforms assert len(manager.platforms) == 1 - assert "Loaded 1 platforms" in caplog.text + get_agents_mock.assert_called_once_with(hass) + +class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): + """Local backup agent.""" + + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup.""" + return "test.tar" + + +@pytest.mark.parametrize( + ("agent_class", "num_local_agents"), + [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)], +) +async def test_loading_platform_with_listener( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_class: type[BackupAgentTest], + num_local_agents: int, +) -> None: + """Test loading a backup agent platform which can be listened to.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, DOMAIN, {}) + manager = hass.data[DATA_MANAGER] + + get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) + register_listener_mock = Mock() + + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=get_agents_mock, + async_register_backup_agents_listener=register_listener_mock, + ), + ) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local"}, + {"agent_id": "test.remote1"}, + ] + assert len(manager.local_backup_agents) == num_local_agents + + get_agents_mock.assert_called_once_with(hass) + register_listener_mock.assert_called_once_with(hass, listener=ANY) + + get_agents_mock.reset_mock() + get_agents_mock.return_value = [agent_class("remote2", backups=[])] + listener = register_listener_mock.call_args[1]["listener"] + listener() + + get_agents_mock.assert_called_once_with(hass) + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local"}, + {"agent_id": "test.remote2"}, + ] + assert len(manager.local_backup_agents) == num_local_agents + + +@pytest.mark.parametrize( + "platform_mock", + [ + Mock(async_pre_backup=AsyncMock(), spec=["async_pre_backup"]), + Mock(async_post_backup=AsyncMock(), spec=["async_post_backup"]), + Mock(spec=[]), + ], +) async def test_not_loading_bad_platforms( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, + platform_mock: Mock, ) -> None: - """Test loading backup platforms.""" - manager = BackupManager(hass) - - assert not manager.loaded_platforms - assert not manager.platforms - - await _setup_mock_domain(hass) - await manager.load_platforms() + """Test not loading bad backup platforms.""" + await _setup_backup_platform( + hass, + domain="test", + platform=platform_mock, + ) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert manager.loaded_platforms - assert len(manager.platforms) == 0 - - assert "Loaded 0 platforms" in caplog.text - assert ( - "some_domain does not implement required functions for the backup platform" - in caplog.text - ) + assert platform_mock.mock_calls == [] -async def test_exception_plaform_pre(hass: HomeAssistant) -> None: +async def test_exception_platform_pre( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test exception in pre step.""" - manager = BackupManager(hass) - manager.loaded_backups = True async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - await _setup_mock_domain( + remote_agent = BackupAgentTest("remote", backups=[]) + await _setup_backup_platform( hass, - Mock( + domain="test", + platform=Mock( async_pre_backup=_mock_step, async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) + + assert "Generating backup failed" in caplog.text + assert "Test exception" in caplog.text -async def test_exception_plaform_post(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_backup_generation") +async def test_exception_platform_post( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test exception in post step.""" - manager = BackupManager(hass) - manager.loaded_backups = True async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - await _setup_mock_domain( + remote_agent = BackupAgentTest("remote", backups=[]) + await _setup_backup_platform( hass, - Mock( + domain="test", + platform=Mock( async_pre_backup=AsyncMock(), async_post_backup=_mock_step, + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) + + assert "Generating backup failed" in caplog.text + assert "Test exception" in caplog.text -async def test_loading_platforms_when_running_async_pre_backup_actions( +@pytest.mark.parametrize( + ( + "agent_id_params", + "open_call_count", + "move_call_count", + "move_path_names", + "remote_agent_backups", + "remote_agent_backup_data", + "temp_file_unlink_call_count", + ), + [ + ( + "agent_id=backup.local&agent_id=test.remote", + 2, + 1, + ["abc123.tar"], + {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + b"test", + 0, + ), + ( + "agent_id=backup.local", + 1, + 1, + ["abc123.tar"], + {}, + None, + 0, + ), + ( + "agent_id=test.remote", + 2, + 0, + [], + {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + b"test", + 1, + ), + ], +) +async def test_receive_backup( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, + hass_client: ClientSessionGenerator, + agent_id_params: str, + open_call_count: int, + move_call_count: int, + move_path_names: list[str], + remote_agent_backups: dict[str, AgentBackup], + remote_agent_backup_data: bytes | None, + temp_file_unlink_call_count: int, ) -> None: - """Test loading backup platforms when running post backup actions.""" - manager = BackupManager(hass) - - assert not manager.loaded_platforms - assert not manager.platforms - - await _setup_mock_domain( + """Test receive backup and upload to the local and a remote agent.""" + remote_agent = BackupAgentTest("remote", backups=[]) + await _setup_backup_platform( hass, - Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, ), ) - await manager.async_pre_backup_actions() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() - assert manager.loaded_platforms - assert len(manager.platforms) == 1 + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) - assert "Loaded 1 platforms" in caplog.text - - -async def test_loading_platforms_when_running_async_post_backup_actions( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backup platforms when running post backup actions.""" - manager = BackupManager(hass) - - assert not manager.loaded_platforms - assert not manager.platforms - - await _setup_mock_domain( - hass, - Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), + with ( + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, ), - ) - await manager.async_post_backup_actions() - - assert manager.loaded_platforms - assert len(manager.platforms) == 1 - - assert "Loaded 1 platforms" in caplog.text - - -async def test_async_receive_backup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test receiving a backup file.""" - manager = BackupManager(hass) - - size = 2 * 2**16 - protocol = Mock(_reading_paused=False) - stream = aiohttp.StreamReader(protocol, 2**16) - stream.feed_data(b"0" * size + b"\r\n--:--") - stream.feed_eof() - - open_mock = mock_open() - - with patch("pathlib.Path.open", open_mock), patch("shutil.move") as mover_mock: - await manager.async_receive_backup( - contents=aiohttp.BodyPartReader( - b"--:", - CIMultiDictProxy( - CIMultiDict( - { - aiohttp.hdrs.CONTENT_DISPOSITION: "attachment; filename=abc123.tar" - } - ) - ), - stream, - ) + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + f"/api/backup/upload?{agent_id_params}", + data={"file": StringIO(upload_data)}, ) - assert open_mock.call_count == 1 - assert mover_mock.call_count == 1 - assert mover_mock.mock_calls[0].args[1].name == "abc123.tar" + await hass.async_block_till_done() + + assert resp.status == 201 + assert open_mock.call_count == open_call_count + assert move_mock.call_count == move_call_count + for index, name in enumerate(move_path_names): + assert move_mock.call_args_list[index].args[1].name == name + assert remote_agent._backups == remote_agent_backups + assert remote_agent._backup_data == remote_agent_backup_data + assert unlink_mock.call_count == temp_file_unlink_call_count +@pytest.mark.usefixtures("mock_backup_generation") +async def test_receive_backup_busy_manager( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test receive backup with a busy manager.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() + ws_client = await hass_ws_client(hass) + + upload_data = "test" + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": "idle"} + + result = await ws_client.receive_json() + assert result["success"] is True + + new_backup = NewBackup(backup_job_id="time-123") + backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == {"backup_job_id": "time-123"} + + assert create_backup.call_count == 1 + + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data={"file": StringIO(upload_data)}, + ) + + assert resp.status == 500 + assert ( + await resp.text() + == "Can't upload backup file: Backup manager busy: create_backup" + ) + + # finish the backup + backup_task.set_result( + WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ) + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"), + [ + (LOCAL_AGENT_ID, None, True, False, "backups"), + (LOCAL_AGENT_ID, "abc123", False, True, "backups"), + ("test.remote", None, True, True, "tmp_backups"), + ], +) async def test_async_trigger_restore( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, + agent_id: str, + password: str | None, + restore_database: bool, + restore_homeassistant: bool, + dir: str, ) -> None: """Test trigger restore.""" - manager = BackupManager(hass) - manager.loaded_backups = True - manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} + manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + hass.data[DATA_MANAGER] = manager + + await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock( + return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] + ), + spec_set=BackupAgentPlatformProtocol, + ), + ) + await manager.load_platforms() + + local_agent = manager.backup_agents[LOCAL_AGENT_ID] + local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} + local_agent._loaded_backups = True with ( patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch.object(BackupAgentTest, "async_download_backup") as download_mock, ): - await manager.async_restore_backup(TEST_BACKUP.slug) - assert mocked_write_text.call_args[0][0] == '{"path": "abc123.tar"}' + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + await manager.async_restore_backup( + TEST_BACKUP_ABC123.backup_id, + agent_id=agent_id, + password=password, + restore_addons=None, + restore_database=restore_database, + restore_folders=None, + restore_homeassistant=restore_homeassistant, + ) + expected_restore_file = json.dumps( + { + "path": f"{hass.config.path()}/{dir}/abc123.tar", + "password": password, + "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + ) + assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called -async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("parameters", "expected_error"), + [ + ( + {"backup_id": TEST_BACKUP_DEF456.backup_id}, + "Backup def456 not found", + ), + ( + {"restore_addons": ["blah"]}, + "Addons and folders are not supported in core restore", + ), + ( + {"restore_folders": [Folder.ADDONS]}, + "Addons and folders are not supported in core restore", + ), + ( + {"restore_database": False, "restore_homeassistant": False}, + "Home Assistant or database must be included in restore", + ), + ], +) +async def test_async_trigger_restore_wrong_parameters( + hass: HomeAssistant, parameters: dict[str, Any], expected_error: str +) -> None: """Test trigger restore.""" - manager = BackupManager(hass) - manager.loaded_backups = True + manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - with pytest.raises(HomeAssistantError, match="Backup abc123 not found"): - await manager.async_restore_backup(TEST_BACKUP.slug) + await _setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) + await manager.load_platforms() + + local_agent = manager.backup_agents[LOCAL_AGENT_ID] + local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} + local_agent._loaded_backups = True + + default_parameters = { + "agent_id": LOCAL_AGENT_ID, + "backup_id": TEST_BACKUP_ABC123.backup_id, + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + } + + with ( + patch("pathlib.Path.exists", return_value=True), + pytest.raises(HomeAssistantError, match=expected_error), + ): + await manager.async_restore_backup(**(default_parameters | parameters)) diff --git a/tests/components/backup/test_models.py b/tests/components/backup/test_models.py new file mode 100644 index 00000000000..6a547f40dc3 --- /dev/null +++ b/tests/components/backup/test_models.py @@ -0,0 +1,11 @@ +"""Tests for the Backup integration.""" + +from homeassistant.components.backup import AgentBackup + +from .common import TEST_BACKUP_ABC123 + + +async def test_agent_backup_serialization() -> None: + """Test AgentBackup serialization.""" + + assert AgentBackup.from_dict(TEST_BACKUP_ABC123.as_dict()) == TEST_BACKUP_ABC123 diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 125ba8adaad..4a94689c19e 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,18 +1,72 @@ """Tests for the Backup integration.""" -from unittest.mock import patch +from collections.abc import Generator +from typing import Any +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup.manager import Backup +from homeassistant.components.backup import AgentBackup, BackupAgentError +from homeassistant.components.backup.agent import BackupAgentUnreachableError +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.components.backup.manager import ( + CreateBackupEvent, + CreateBackupState, + ManagerBackup, + NewBackup, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .common import TEST_BACKUP, setup_backup_integration +from .common import ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + BackupAgentTest, + setup_backup_integration, +) +from tests.common import async_fire_time_changed, async_mock_service from tests.typing import WebSocketGenerator +BACKUP_CALL = call( + agent_ids=["test.test-agent"], + backup_name="test-name", + include_addons=["test-addon"], + include_all_addons=False, + include_database=True, + include_folders=["media"], + include_homeassistant=True, + password="test-password", + on_progress=ANY, +) + +DEFAULT_STORAGE_DATA: dict[str, Any] = { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, +} + @pytest.fixture def sync_access_token_proxy( @@ -26,145 +80,542 @@ def sync_access_token_proxy( return request.getfixturevalue(access_token_fixture_name) +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield + + +@pytest.fixture(name="delete_backup") +def mock_delete_backup() -> Generator[AsyncMock]: + """Mock manager delete backup.""" + with patch( + "homeassistant.components.backup.BackupManager.async_delete_backup" + ) as mock_delete_backup: + yield mock_delete_backup + + +@pytest.fixture(name="get_backups") +def mock_get_backups() -> Generator[AsyncMock]: + """Mock manager get backups.""" + with patch( + "homeassistant.components.backup.BackupManager.async_get_backups" + ) as mock_get_backups: + yield mock_get_backups + + @pytest.mark.parametrize( - "with_hassio", + ("remote_agents", "remote_backups"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ([], {}), + (["remote"], {}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ], ) async def test_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + remote_backups: dict[str, list[AgentBackup]], snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test getting backup info.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} | remote_backups, + remote_agents=remote_agents, + ) client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value={TEST_BACKUP.slug: TEST_BACKUP}, - ): - await client.send_json_auto_id({"type": "backup/info"}) - assert snapshot == await client.receive_json() + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "backup_content", - [ - pytest.param(TEST_BACKUP, id="with_backup_content"), - pytest.param(None, id="without_backup_content"), - ], + "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] ) +async def test_info_with_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + side_effect: Exception, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info with one unavailable agent.""" + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect): + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( - "with_hassio", + ("remote_agents", "backups"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ([], {}), + (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + ( + ["remote"], + { + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + ), ], ) async def test_details( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + backups: dict[str, list[AgentBackup]], snapshot: SnapshotAssertion, - with_hassio: bool, - backup_content: Backup | None, ) -> None: """Test getting backup info.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration( + hass, with_hassio=False, backups=backups, remote_agents=remote_agents + ) client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=backup_content, - ): - await client.send_json_auto_id({"type": "backup/details", "slug": "abc123"}) + with patch("pathlib.Path.exists", return_value=True): + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "with_hassio", - [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ], + "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] ) -async def test_remove( +async def test_details_with_errors( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + side_effect: Exception, snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: - """Test removing a backup file.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + """Test getting backup info with one unavailable agent.""" + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_remove_backup", + with ( + patch("pathlib.Path.exists", return_value=True), + patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect), ): - await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) - assert snapshot == await client.receive_json() + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "with_hassio", + ("remote_agents", "backups"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ([], {}), + (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + ( + ["remote"], + { + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + ), ], ) +async def test_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + backups: dict[str, list[AgentBackup]], + snapshot: SnapshotAssertion, +) -> None: + """Test deleting a backup file.""" + await setup_backup_integration( + hass, with_hassio=False, backups=backups, remote_agents=remote_agents + ) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "storage_data", + [ + DEFAULT_STORAGE_DATA, + DEFAULT_STORAGE_DATA + | { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + "with_strategy_settings": False, + } + ] + }, + ], +) +@pytest.mark.parametrize( + "side_effect", [None, HomeAssistantError("Boom!"), BackupAgentUnreachableError] +) +async def test_delete_with_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + side_effect: Exception, + storage_data: dict[str, Any] | None, + snapshot: SnapshotAssertion, +) -> None: + """Test deleting a backup with one unavailable agent.""" + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": 1, + } + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect): + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + +async def test_agent_delete_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test deleting a backup file with a mock agent.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock: + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "abc123", + } + ) + assert await client.receive_json() == snapshot + + assert delete_mock.call_args == call("abc123") + + +@pytest.mark.parametrize( + "data", + [ + None, + {}, + {"password": "abc123"}, + ], +) +@pytest.mark.usefixtures("mock_backup_generation") async def test_generate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + data: dict[str, Any] | None, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test generating a backup.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration(hass, with_hassio=False) client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_create_backup", - return_value=TEST_BACKUP, - ): - await client.send_json_auto_id({"type": "backup/generate"}) - assert snapshot == await client.receive_json() + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + await client.send_json_auto_id( + {"type": "backup/generate", **{"agent_ids": ["backup.local"]} | (data or {})} + ) + for _ in range(6): + assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "with_hassio", + ("parameters", "expected_error"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ( + {"include_homeassistant": False}, + "Home Assistant must be included in backup", + ), + ( + {"include_addons": ["blah"]}, + "Addons and folders are not supported by core backup", + ), + ( + {"include_all_addons": True}, + "Addons and folders are not supported by core backup", + ), + ( + {"include_folders": ["ssl"]}, + "Addons and folders are not supported by core backup", + ), ], ) -async def test_restore( +async def test_generate_wrong_parameters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test generating a backup.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + default_parameters = {"type": "backup/generate", "agent_ids": ["backup.local"]} + + await client.send_json_auto_id(default_parameters | parameters) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": expected_error, + } + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("params", "expected_extra_call_params"), + [ + ({"agent_ids": ["backup.local"]}, {"agent_ids": ["backup.local"]}), + ( + { + "agent_ids": ["backup.local"], + "include_database": False, + "name": "abc123", + }, + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_database": False, + "include_folders": None, + "name": "abc123", + }, + ), + ], +) +async def test_generate_calls_create( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + params: dict[str, Any], + expected_extra_call_params: dict[str, Any], +) -> None: + """Test translation of WS parameter to backup/generate to async_initiate_backup.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_initiate_backup", + return_value=NewBackup(backup_job_id="abc123"), + ) as generate_backup: + await client.send_json_auto_id({"type": "backup/generate"} | params) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} + generate_backup.assert_called_once_with( + **{ + "include_all_addons": False, + "include_homeassistant": True, + "include_addons": None, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + } + | expected_extra_call_params + ) + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("create_backup_settings", "expected_call_params"), + [ + ( + {}, + { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_strategy_settings": True, + }, + ), + ( + { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "include_homeassistant": True, + "name": "test-name", + "password": "test-password", + "with_strategy_settings": True, + }, + ), + ], +) +async def test_generate_with_default_settings_calls_create( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + create_backup_settings: dict[str, Any], + expected_call_params: dict[str, Any], +) -> None: + """Test backup/generate_with_strategy_settings calls async_initiate_backup.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") + await hass.async_block_till_done() + + await client.send_json_auto_id( + {"type": "backup/config/update", "create_backup": create_backup_settings} + ) + result = await client.receive_json() + assert result["success"] + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_initiate_backup", + return_value=NewBackup(backup_job_id="abc123"), + ) as generate_backup: + await client.send_json_auto_id( + {"type": "backup/generate_with_strategy_settings"} + ) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} + generate_backup.assert_called_once_with(**expected_call_params) + + +@pytest.mark.parametrize( + "backups", + [ + {}, + {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + ], +) +async def test_restore_local_agent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + backups: dict[str, list[AgentBackup]], snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test calling the restore command.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration(hass, with_hassio=False, backups=backups) + restart_calls = async_mock_service(hass, "homeassistant", "restart") client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.write_text"), ): - await client.send_json_auto_id({"type": "backup/restore", "slug": "abc123"}) + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "backup.local", + } + ) assert await client.receive_json() == snapshot + assert len(restart_calls) == snapshot + + +@pytest.mark.parametrize( + ("remote_agents", "backups"), + [ + (["remote"], {}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + ], +) +async def test_restore_remote_agent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + backups: dict[str, list[AgentBackup]], + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command.""" + await setup_backup_integration( + hass, with_hassio=False, backups=backups, remote_agents=remote_agents + ) + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch("pathlib.Path.write_text"), patch("pathlib.Path.open"): + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "test.remote", + } + ) + assert await client.receive_json() == snapshot + assert len(restart_calls) == snapshot @pytest.mark.parametrize( @@ -178,6 +629,7 @@ async def test_restore( pytest.param(False, id="without_hassio"), ], ) +@pytest.mark.usefixtures("supervisor_client") async def test_backup_end( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -197,7 +649,7 @@ async def test_backup_end( "homeassistant.components.backup.manager.BackupManager.async_post_backup_actions", ): await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -211,6 +663,7 @@ async def test_backup_end( pytest.param(False, id="without_hassio"), ], ) +@pytest.mark.usefixtures("supervisor_client") async def test_backup_start( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -230,7 +683,7 @@ async def test_backup_start( "homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions", ): await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -241,7 +694,8 @@ async def test_backup_start( Exception("Boom"), ], ) -async def test_backup_end_excepion( +@pytest.mark.usefixtures("supervisor_client") +async def test_backup_end_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -259,7 +713,7 @@ async def test_backup_end_excepion( side_effect=exception, ): await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -270,7 +724,8 @@ async def test_backup_end_excepion( Exception("Boom"), ], ) -async def test_backup_start_excepion( +@pytest.mark.usefixtures("supervisor_client") +async def test_backup_start_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -288,4 +743,1207 @@ async def test_backup_start_excepion( side_effect=exception, ): await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup agents info.""" + await setup_backup_integration(hass, with_hassio=False) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.parametrize( + "storage_data", + [ + None, + { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": 3, "days": 7}, + "last_attempted_strategy_backup": "2024-10-26T04:45:00+01:00", + "last_completed_strategy_backup": "2024-10-26T04:45:00+01:00", + "schedule": {"state": "daily"}, + }, + }, + { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": 3, "days": None}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + "schedule": {"state": "never"}, + }, + }, + { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": 7}, + "last_attempted_strategy_backup": "2024-10-27T04:45:00+01:00", + "last_completed_strategy_backup": "2024-10-26T04:45:00+01:00", + "schedule": {"state": "never"}, + }, + }, + { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + "schedule": {"state": "mon"}, + }, + }, + { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + "schedule": {"state": "sat"}, + }, + }, + ], +) +async def test_config_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + hass_storage: dict[str, Any], + storage_data: dict[str, Any] | None, +) -> None: + """Test getting backup config info.""" + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": 1, + } + + await setup_backup_integration(hass) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.parametrize( + "command", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": "mon", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": "never", + }, + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": 7}, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3}, + "schedule": "daily", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"days": 7}, + "schedule": "daily", + }, + ], +) +async def test_config_update( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + command: dict[str, Any], + hass_storage: dict[str, Any], +) -> None: + """Test updating the backup config.""" + await setup_backup_integration(hass) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert result["success"] + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot + + +@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.parametrize( + "command", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": "someday", + }, + ], +) +async def test_config_update_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + command: dict[str, Any], +) -> None: + """Test errors when updating the backup config.""" + await setup_backup_integration(hass) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert not result["success"] + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ( + "command", + "last_completed_strategy_backup", + "time_1", + "time_2", + "attempted_backup_time", + "completed_backup_time", + "backup_calls_1", + "backup_calls_2", + "call_args", + "create_backup_side_effect", + ), + [ + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + }, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "mon", + }, + "2024-11-11T04:45:00+01:00", + "2024-11-18T04:45:00+01:00", + "2024-11-25T04:45:00+01:00", + "2024-11-18T04:45:00+01:00", + "2024-11-18T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "never", + }, + "2024-11-11T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-11T13:00:00+01:00", + "2024-11-11T04:45:00+01:00", + "2024-11-11T04:45:00+01:00", + 0, + 0, + None, + None, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + }, + "2024-10-26T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "mon", + }, + "2024-10-26T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once + 1, + 1, + BACKUP_CALL, + None, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "never", + }, + "2024-10-26T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-12T12:00:00+01:00", + "2024-10-26T04:45:00+01:00", + "2024-10-26T04:45:00+01:00", + 0, + 0, + None, + None, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": "daily", + }, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-13T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-11T04:45:00+01:00", + 1, + 2, + BACKUP_CALL, + [Exception("Boom"), None], + ), + ], +) +async def test_config_schedule_logic( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, + command: dict[str, Any], + last_completed_strategy_backup: str, + time_1: str, + time_2: str, + attempted_backup_time: str, + completed_backup_time: str, + backup_calls_1: int, + backup_calls_2: int, + call_args: Any, + create_backup_side_effect: list[Exception | None] | None, +) -> None: + """Test config schedule logic.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_strategy_backup": last_completed_strategy_backup, + "last_completed_strategy_backup": last_completed_strategy_backup, + "schedule": {"state": "daily"}, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": 1, + } + create_backup.side_effect = create_backup_side_effect + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-11 12:00:00+01:00") + + await setup_backup_integration(hass, remote_agents=["test-agent"]) + await hass.async_block_till_done() + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert result["success"] + + freezer.move_to(time_1) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert create_backup.call_count == backup_calls_1 + assert create_backup.call_args == call_args + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_strategy_backup"] + == attempted_backup_time + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_strategy_backup"] + == completed_backup_time + ) + + freezer.move_to(time_2) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert create_backup.call_count == backup_calls_2 + assert create_backup.call_args == call_args + + +@pytest.mark.parametrize( + ( + "command", + "backups", + "get_backups_agent_errors", + "delete_backup_agent_errors", + "last_backup_time", + "next_time", + "backup_time", + "backup_calls", + "get_backups_calls", + "delete_calls", + "delete_args_list", + ), + [ + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, # we get backups even if backup retention copies is None + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {"test-agent": BackupAgentError("Boom!")}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {"test-agent": BackupAgentError("Boom!")}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 0, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 3, + [call("backup-1"), call("backup-2"), call("backup-3")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 0, "days": None}, + "schedule": "daily", + }, + { + "backup-1": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 0, + [], + ), + ], +) +async def test_config_retention_copies_logic( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, + delete_backup: AsyncMock, + get_backups: AsyncMock, + command: dict[str, Any], + backups: dict[str, Any], + get_backups_agent_errors: dict[str, Exception], + delete_backup_agent_errors: dict[str, Exception], + last_backup_time: str, + next_time: str, + backup_time: str, + backup_calls: int, + get_backups_calls: int, + delete_calls: int, + delete_args_list: Any, +) -> None: + """Test config backup retention copies logic.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": last_backup_time, + "schedule": {"state": "daily"}, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": 1, + } + get_backups.return_value = (backups, get_backups_agent_errors) + delete_backup.return_value = delete_backup_agent_errors + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-11 12:00:00+01:00") + + await setup_backup_integration(hass, remote_agents=["test-agent"]) + await hass.async_block_till_done() + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert result["success"] + + freezer.move_to(next_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert create_backup.call_count == backup_calls + assert get_backups.call_count == get_backups_calls + assert delete_backup.call_count == delete_calls + assert delete_backup.call_args_list == delete_args_list + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_strategy_backup"] + == backup_time + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_strategy_backup"] + == backup_time + ) + + +@pytest.mark.parametrize( + ( + "command", + "backups", + "get_backups_agent_errors", + "delete_backup_agent_errors", + "last_backup_time", + "start_time", + "next_time", + "get_backups_calls", + "delete_calls", + "delete_args_list", + ), + [ + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 3}, + "schedule": "never", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {"test-agent": BackupAgentError("Boom!")}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": "never", + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {"test-agent": BackupAgentError("Boom!")}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 0}, + "schedule": "never", + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_strategy_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_strategy_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ], +) +async def test_config_retention_days_logic( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + delete_backup: AsyncMock, + get_backups: AsyncMock, + command: dict[str, Any], + backups: dict[str, Any], + get_backups_agent_errors: dict[str, Exception], + delete_backup_agent_errors: dict[str, Exception], + last_backup_time: str, + start_time: str, + next_time: str, + get_backups_calls: int, + delete_calls: int, + delete_args_list: list[Any], +) -> None: + """Test config backup retention logic.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": {}, + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": last_backup_time, + "schedule": {"state": "never"}, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": 1, + } + get_backups.return_value = (backups, get_backups_agent_errors) + delete_backup.return_value = delete_backup_agent_errors + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to(start_time) + + await setup_backup_integration(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert result["success"] + + freezer.move_to(next_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert get_backups.call_count == get_backups_calls + assert delete_backup.call_count == delete_calls + assert delete_backup.call_args_list == delete_args_list + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + + +async def test_subscribe_event( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test generating a backup.""" + await setup_backup_integration(hass, with_hassio=False) + + manager = hass.data[DATA_MANAGER] + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + assert await client.receive_json() == snapshot + + manager.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + ) + assert await client.receive_json() == snapshot diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index e9540b5cec6..d7f9a045921 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Beosound Balance-11111111', 'unique_id': '11111111', 'version': 1, diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index 36fcc72aa22..327b7ecfacf 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -243,7 +243,7 @@ 'state': 'playing', }) # --- -# name: test_async_beolink_join +# name: test_async_beolink_join[service_parameters0-method_parameters0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'beolink': dict({ @@ -291,6 +291,240 @@ 'state': 'playing', }) # --- +# name: test_async_beolink_join[service_parameters1-method_parameters1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join[service_parameters2-method_parameters2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join_invalid[service_parameters0-expected_result0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join_invalid[service_parameters1-expected_result1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join_invalid[service_parameters2-expected_result2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- # name: test_async_beolink_unexpand StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index aa35b0265dc..695b086b0a7 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -18,6 +18,7 @@ from mozart_api.models import ( import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props +from voluptuous import Invalid, MultipleInvalid from homeassistant.components.bang_olufsen.const import ( BANG_OLUFSEN_REPEAT_FROM_HA, @@ -1523,13 +1524,38 @@ async def test_async_unjoin_player( assert states == snapshot(exclude=props("media_position_updated_at")) +@pytest.mark.parametrize( + ( + "service_parameters", + "method_parameters", + ), + [ + # Defined JID + ( + {"beolink_jid": TEST_JID_2}, + {"jid": TEST_JID_2}, + ), + # Defined JID and source + ( + {"beolink_jid": TEST_JID_2, "source_id": TEST_SOURCE.id}, + {"jid": TEST_JID_2, "source": TEST_SOURCE.id}, + ), + # Defined JID and Beolink Converter NL/ML source + ( + {"beolink_jid": TEST_JID_2, "source_id": "cd"}, + {"jid": TEST_JID_2, "source": "CD"}, + ), + ], +) async def test_async_beolink_join( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, + service_parameters: dict[str, str], + method_parameters: dict[str, str], ) -> None: - """Test async_beolink_join with defined JID.""" + """Test async_beolink_join with defined JID and JID and source.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -1537,14 +1563,61 @@ async def test_async_beolink_join( await hass.services.async_call( DOMAIN, "beolink_join", - { - ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, - "beolink_jid": TEST_JID_2, - }, + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters}, blocking=True, ) - mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2) + mock_mozart_client.join_beolink_peer.assert_called_once_with(**method_parameters) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +@pytest.mark.parametrize( + ( + "service_parameters", + "expected_result", + ), + [ + # Defined invalid JID + ( + {"beolink_jid": "not_a_jid"}, + pytest.raises(Invalid), + ), + # Defined invalid source + ( + {"source_id": "invalid_source"}, + pytest.raises(MultipleInvalid), + ), + # Defined invalid JID and invalid source + ( + {"beolink_jid": "not_a_jid", "source_id": "invalid_source"}, + pytest.raises(MultipleInvalid), + ), + ], +) +async def test_async_beolink_join_invalid( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service_parameters: dict[str, str], + expected_result: AbstractContextManager, +) -> None: + """Test invalid async_beolink_join calls with defined JID or source ID.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with expected_result: + await hass.services.async_call( + DOMAIN, + "beolink_join", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters}, + blocking=True, + ) + + mock_mozart_client.join_beolink_peer.assert_not_called() assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states == snapshot(exclude=props("media_position_updated_at")) diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index edc2879a66b..54df2b48cdb 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -48,6 +48,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 3, diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index f490b854749..c437e1d3669 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -48,6 +48,11 @@ FIXTURE_CONFIG_ENTRY = { "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}", } +REMOTE_SERVICE_EXC_REASON = "HTTPStatusError: 502 Bad Gateway" +REMOTE_SERVICE_EXC_TRANSLATION = ( + "Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway" +) + async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py index 88c7990cde9..356cfcb439e 100644 --- a/tests/components/bmw_connected_drive/test_button.py +++ b/tests/components/bmw_connected_drive/test_button.py @@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -81,11 +85,13 @@ async def test_service_call_fail( monkeypatch.setattr( RemoteServices, "trigger_remote_service", - AsyncMock(side_effect=MyBMWRemoteServiceError), + AsyncMock( + side_effect=MyBMWRemoteServiceError("HTTPStatusError: 502 Bad Gateway") + ), ) # Test - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION): await hass.services.async_call( "button", "press", diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 8fa9d9be22b..9c124261392 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -224,19 +224,11 @@ async def test_reauth(hass: HomeAssistant) -> None: result = await config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - suggested_values = { - key: key.description.get("suggested_value") - for key in result["data_schema"].schema - } - assert suggested_values[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert suggested_values[CONF_PASSWORD] == wrong_password - assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION] + assert result["step_id"] == "change_password" + assert set(result["data_schema"].schema) == {CONF_PASSWORD} result = await hass.config_entries.flow.async_configure( - result["flow_id"], deepcopy(FIXTURE_USER_INPUT) + result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]} ) await hass.async_block_till_done() @@ -254,41 +246,6 @@ async def test_reauth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_reauth_unique_id_abort(hass: HomeAssistant) -> None: - """Test aborting the reauth form if unique_id changes.""" - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=login_sideeffect, - autospec=True, - ): - wrong_password = "wrong" - - config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY) - config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password - - config_entry = MockConfigEntry(**config_entry_with_wrong_password) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.data == config_entry_with_wrong_password["data"] - - result = await config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {**FIXTURE_USER_INPUT, CONF_REGION: "north_america"} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "account_mismatch" - assert config_entry.data == config_entry_with_wrong_password["data"] - - async def test_reconfigure(hass: HomeAssistant) -> None: """Test the reconfiguration form.""" with patch( @@ -304,19 +261,11 @@ async def test_reconfigure(hass: HomeAssistant) -> None: result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - suggested_values = { - key: key.description.get("suggested_value") - for key in result["data_schema"].schema - } - assert suggested_values[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert suggested_values[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] - assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION] + assert result["step_id"] == "change_password" + assert set(result["data_schema"].schema) == {CONF_PASSWORD} result = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT + result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]} ) await hass.async_block_till_done() @@ -330,32 +279,3 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data == FIXTURE_COMPLETE_ENTRY - - -async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: - """Test aborting the reconfiguration form if unique_id changes.""" - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=login_sideeffect, - autospec=True, - ): - config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await config_entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {**FIXTURE_USER_INPUT, CONF_USERNAME: "somebody@email.com"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "account_mismatch" - assert config_entry.data == FIXTURE_COMPLETE_ENTRY diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index 774a85eb6da..beb3d74d572 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -33,7 +33,7 @@ async def test_update_success(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.runtime_data.coordinator.last_update_success is True + assert config_entry.runtime_data.last_update_success is True @pytest.mark.usefixtures("bmw_fixture") @@ -48,7 +48,7 @@ async def test_update_failed( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data assert coordinator.last_update_success is True @@ -77,7 +77,7 @@ async def test_update_reauth( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data assert coordinator.last_update_success is True @@ -146,7 +146,7 @@ async def test_captcha_reauth( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data assert coordinator.last_update_success is True diff --git a/tests/components/bmw_connected_drive/test_lock.py b/tests/components/bmw_connected_drive/test_lock.py index 2fa694d426b..088534c79f5 100644 --- a/tests/components/bmw_connected_drive/test_lock.py +++ b/tests/components/bmw_connected_drive/test_lock.py @@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform from tests.components.recorder.common import async_wait_recording_done @@ -118,11 +123,11 @@ async def test_service_call_fail( monkeypatch.setattr( RemoteServices, "trigger_remote_service", - AsyncMock(side_effect=MyBMWRemoteServiceError), + AsyncMock(side_effect=MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON)), ) # Test - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION): await hass.services.async_call( "lock", service, diff --git a/tests/components/bmw_connected_drive/test_notify.py b/tests/components/bmw_connected_drive/test_notify.py index 4113f618be0..1bade3be011 100644 --- a/tests/components/bmw_connected_drive/test_notify.py +++ b/tests/components/bmw_connected_drive/test_notify.py @@ -11,7 +11,11 @@ import respx from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) async def test_legacy_notify_service_simple( @@ -68,21 +72,21 @@ async def test_legacy_notify_service_simple( { "latitude": POI_DATA.get("lat"), }, - "Invalid data for point of interest: required key not provided @ data['longitude']", + r"Invalid data for point of interest: required key not provided @ data\['longitude'\]", ), ( { "latitude": POI_DATA.get("lat"), "longitude": "text", }, - "Invalid data for point of interest: invalid longitude for dictionary value @ data['longitude']", + r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]", ), ( { "latitude": POI_DATA.get("lat"), "longitude": 9999, }, - "Invalid data for point of interest: invalid longitude for dictionary value @ data['longitude']", + r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]", ), ], ) @@ -96,7 +100,7 @@ async def test_service_call_invalid_input( # Setup component assert await setup_mocked_integration(hass) - with pytest.raises(ServiceValidationError) as exc: + with pytest.raises(ServiceValidationError, match=exc_translation): await hass.services.async_call( "notify", "bmw_connected_drive_ix_xdrive50", @@ -106,7 +110,6 @@ async def test_service_call_invalid_input( }, blocking=True, ) - assert str(exc.value) == exc_translation @pytest.mark.usefixtures("bmw_fixture") @@ -132,11 +135,11 @@ async def test_service_call_fail( monkeypatch.setattr( RemoteServices, "trigger_remote_service", - AsyncMock(side_effect=raised), + AsyncMock(side_effect=raised("HTTPStatusError: 502 Bad Gateway")), ) # Test - with pytest.raises(expected): + with pytest.raises(expected, match=REMOTE_SERVICE_EXC_TRANSLATION): await hass.services.async_call( "notify", "bmw_connected_drive_ix_xdrive50", diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index f2a50ce4df6..733f4fe3113 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -13,7 +13,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -89,7 +94,10 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="Target SoC must be an integer between 20 and 100 that is a multiple of 5.", + ): await hass.services.async_call( "number", "set_value", @@ -102,17 +110,32 @@ async def test_service_call_invalid_input( @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( - ("raised", "expected"), + ("raised", "expected", "exc_translation"), [ - (MyBMWRemoteServiceError, HomeAssistantError), - (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + ( + MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + MyBMWAPIError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + ValueError( + "Target SoC must be an integer between 20 and 100 that is a multiple of 5." + ), + ValueError, + "Target SoC must be an integer between 20 and 100 that is a multiple of 5.", + ), ], ) async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, + exc_translation: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -130,7 +153,7 @@ async def test_service_call_fail( ) # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "number", "set_value", diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index a270f38ee01..53c39f572f2 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.translation import async_get_translations -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -105,7 +110,10 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ServiceValidationError): + with pytest.raises( + ServiceValidationError, + match=f"Option {value} is not valid for entity {entity_id}", + ): await hass.services.async_call( "select", "select_option", @@ -118,17 +126,32 @@ async def test_service_call_invalid_input( @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( - ("raised", "expected"), + ("raised", "expected", "exc_translation"), [ - (MyBMWRemoteServiceError, HomeAssistantError), - (MyBMWAPIError, HomeAssistantError), - (ServiceValidationError, ServiceValidationError), + ( + MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + MyBMWAPIError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + ServiceValidationError( + "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit" + ), + ServiceValidationError, + "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit", + ), ], ) async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, + exc_translation: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -146,7 +169,7 @@ async def test_service_call_fail( ) # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "select", "select_option", diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index 58bddbfc937..c28b651abaf 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -13,7 +13,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -75,17 +80,25 @@ async def test_service_call_success( @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( - ("raised", "expected"), + ("raised", "expected", "exc_translation"), [ - (MyBMWRemoteServiceError, HomeAssistantError), - (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + ( + MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + MyBMWAPIError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), ], ) async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, + exc_translation: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -107,7 +120,7 @@ async def test_service_call_fail( assert hass.states.get(entity_id).state == old_value # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "switch", "turn_on", @@ -122,7 +135,7 @@ async def test_service_call_fail( assert hass.states.get(entity_id).state == old_value # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "switch", "turn_off", diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index cd29c647df7..de76c00cd23 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 8d215a5d3ee..93e86051a75 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -188,3 +188,29 @@ async def test_flow_reauth_error_and_recover( assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reauth_unique_id_mismatch( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test we abort reauth if unique id mismatch.""" + + mock_bring_client.uuid = "11111111-11111111-11111111-11111111" + + bring_config_entry.add_to_hass(hass) + + result = await bring_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py index 9a2d077b8f8..8d01db6e015 100644 --- a/tests/components/cambridge_audio/test_config_flow.py +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -7,7 +7,7 @@ from aiostreammagic import StreamMagicError from homeassistant.components.cambridge_audio.const import DOMAIN from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -192,3 +192,55 @@ async def test_zeroconf_duplicate( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + {CONF_HOST: "192.168.20.219"}, + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: "192.168.20.219", + } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reconfigure flow aborts when the bride changes.""" + mock_stream_magic_client.info.unit_id = "different_udn" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 32520fcad23..a3045e27cf1 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -826,26 +826,6 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockCamera(camera.Camera): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockCamera() - assert entity.supported_features_compat is camera.CameraEntityFeature(1) - assert "MockCamera" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CameraEntityFeature.ON_OFF" in caplog.text - caplog.clear() - assert entity.supported_features_compat is camera.CameraEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 3fd696f5953..907071d8b1f 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -7,13 +7,12 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.components.cert_expiry.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import HOST, PORT -from .helpers import future_timestamp from tests.common import MockConfigEntry @@ -64,122 +63,6 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None: assert result["result"].unique_id == f"{HOST}:{PORT}" -async def test_import_host_only(hass: HomeAssistant) -> None: - """Test import with host only.""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT - assert result["result"].unique_id == f"{HOST}:{DEFAULT_PORT}" - - -async def test_import_host_and_port(hass: HomeAssistant) -> None: - """Test import with host and port.""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - assert result["result"].unique_id == f"{HOST}:{PORT}" - - -async def test_import_non_default_port(hass: HomeAssistant) -> None: - """Test import with host and non-default port.""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: 888}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == f"{HOST}:888" - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == 888 - assert result["result"].unique_id == f"{HOST}:888" - - -async def test_import_with_name(hass: HomeAssistant) -> None: - """Test import with name (deprecated).""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_NAME: "legacy", CONF_HOST: HOST, CONF_PORT: PORT}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - assert result["result"].unique_id == f"{HOST}:{PORT}" - - -async def test_bad_import(hass: HomeAssistant) -> None: - """Test import step.""" - with patch( - "homeassistant.components.cert_expiry.helper.async_get_cert", - side_effect=ConnectionRefusedError(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "import_failed" - - async def test_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if the cert is already setup.""" MockConfigEntry( @@ -188,14 +71,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: unique_id=f"{HOST}:{PORT}", ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index e2c333cc6f3..5ba63ad1af1 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -1,59 +1,24 @@ """Tests for Cert Expiry setup.""" -from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time from homeassistant.components.cert_expiry.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from .const import HOST, PORT from .helpers import future_timestamp, static_datetime -from tests.common import MockConfigEntry, async_fire_time_changed - - -async def test_setup_with_config(hass: HomeAssistant) -> None: - """Test setup component with config.""" - assert hass.state is CoreState.running - - config = { - SENSOR_DOMAIN: [ - {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT}, - {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: 888}, - ], - } - - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) is True - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - next_update = dt_util.utcnow() + timedelta(seconds=20) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 +from tests.common import MockConfigEntry async def test_update_unique_id(hass: HomeAssistant) -> None: diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index a7f47668612..45570c63008 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -4,7 +4,7 @@ from __future__ import annotations from enum import Enum from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock import pytest import voluptuous as vol @@ -38,13 +38,7 @@ from homeassistant.components.climate.const import ( ClimateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_WHOLE, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir @@ -430,289 +424,6 @@ async def test_mode_validation( assert exc.value.translation_key == "not_valid_fan_mode" -@pytest.mark.parametrize( - "supported_features_at_int", - [ - ClimateEntityFeature.TARGET_TEMPERATURE.value, - ClimateEntityFeature.TARGET_TEMPERATURE.value - | ClimateEntityFeature.TURN_ON.value - | ClimateEntityFeature.TURN_OFF.value, - ], -) -def test_deprecated_supported_features_ints( - caplog: pytest.LogCaptureFixture, supported_features_at_int: int -) -> None: - """Test deprecated supported features ints.""" - - class MockClimateEntity(ClimateEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return supported_features_at_int - - entity = MockClimateEntity() - assert entity.supported_features is ClimateEntityFeature(supported_features_at_int) - assert "MockClimateEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text - caplog.clear() - assert entity.supported_features is ClimateEntityFeature(supported_features_at_int) - assert "is using deprecated supported features values" not in caplog.text - - -async def test_warning_not_implemented_turn_on_off_feature( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test adding feature flag and warn if missing when methods are set.""" - - called = [] - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - def turn_on(self) -> None: - """Turn on.""" - called.append("turn_on") - - def turn_off(self) -> None: - """Turn off.""" - called.append("turn_off") - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - " Please report it to the author of the 'test' custom integration" - in caplog.text - ) - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." - " Please report it to the author of the 'test' custom integration" - in caplog.text - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON, - { - "entity_id": "climate.test", - }, - blocking=True, - ) - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_OFF, - { - "entity_id": "climate.test", - }, - blocking=True, - ) - - assert len(called) == 2 - assert "turn_on" in called - assert "turn_off" in called - - -async def test_implicit_warning_not_implemented_turn_on_off_feature( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test adding feature flag and warn if missing when methods are not set. - - (implicit by hvac mode) - """ - - class MockClimateEntityTest(MockEntity, ClimateEntity): - """Mock Climate device.""" - - _attr_temperature_unit = UnitOfTemperature.CELSIUS - - @property - def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVACMode.*. - """ - return HVACMode.HEAT - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return [HVACMode.OFF, HVACMode.HEAT] - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " implements HVACMode(s): off, heat and therefore implicitly supports the turn_on/turn_off" - " methods without setting the proper ClimateEntityFeature. Please report it to the author" - " of the 'test' custom integration" in caplog.text - ) - - -async def test_no_warning_implemented_turn_on_off_feature( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test no warning when feature flags are set.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - not in caplog.text - ) - assert ( - "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." - not in caplog.text - ) - assert ( - " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" - not in caplog.text - ) - - -async def test_no_warning_integration_has_migrated( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - _enable_turn_on_off_backwards_compatibility = False - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - ) - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - not in caplog.text - ) - assert ( - "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." - not in caplog.text - ) - assert ( - " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" - not in caplog.text - ) - - -async def test_no_warning_integration_implement_feature_flags( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test no warning when integration uses the correct feature flags.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert "does not set ClimateEntityFeature" not in caplog.text - assert "implements HVACMode(s):" not in caplog.text - - async def test_turn_on_off_toggle(hass: HomeAssistant) -> None: """Test turn_on/turn_off/toggle methods.""" @@ -751,7 +462,6 @@ async def test_sync_toggle(hass: HomeAssistant) -> None: class MockClimateEntityTest(MockClimateEntity): """Mock Climate device.""" - _enable_turn_on_off_backwards_compatibility = False _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py new file mode 100644 index 00000000000..ac0ef1826de --- /dev/null +++ b/tests/components/cloud/test_backup.py @@ -0,0 +1,573 @@ +"""Test the cloud backup platform.""" + +from collections.abc import AsyncGenerator, AsyncIterator, Generator +from io import StringIO +from typing import Any +from unittest.mock import Mock, PropertyMock, patch + +from aiohttp import ClientError +from hass_nabucasa import CloudError +import pytest +from yarl import URL + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.components.cloud import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + cloud: MagicMock, + cloud_logged_in: None, +) -> AsyncGenerator[None]: + """Set up cloud integration.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + yield + + +@pytest.fixture +def mock_delete_file() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_delete_file", + spec_set=True, + ) as delete_file: + yield delete_file + + +@pytest.fixture +def mock_get_download_details() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_download_details", + spec_set=True, + ) as download_details: + download_details.return_value = { + "url": ( + "https://blabla.cloudflarestorage.com/blabla/backup/" + "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" + ), + } + yield download_details + + +@pytest.fixture +def mock_get_upload_details() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_upload_details", + spec_set=True, + ) as download_details: + download_details.return_value = { + "url": ( + "https://blabla.cloudflarestorage.com/blabla/backup/" + "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" + ), + "headers": { + "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", + "x-amz-meta-storage-type": "backup", + "x-amz-meta-b64json": ( + "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" + "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" + "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" + "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" + "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" + ), + }, + } + yield download_details + + +@pytest.fixture +def mock_list_files() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_list", spec_set=True + ) as list_files: + list_files.return_value = [ + { + "Key": "462e16810d6841228828d9dd2f9e341e.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", + }, + } + ] + yield list_files + + +@pytest.fixture +def cloud_logged_in(cloud: MagicMock): + """Mock cloud logged in.""" + type(cloud).is_logged_in = PropertyMock(return_value=True) + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + mock_list_files: Mock, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + mock_list_files.assert_called_once_with(cloud, storage_type="backup") + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": ["cloud.cloud"], + "failed_agent_ids": [], + "with_strategy_settings": False, + } + ] + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +async def test_agents_list_backups_fail_cloud( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + mock_list_files: Mock, + side_effect: Exception, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + mock_list_files.side_effect = side_effect + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"cloud.cloud": "Failed to list backups"}, + "backups": [], + "last_attempted_strategy_backup": None, + "last_completed_strategy_backup": None, + } + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + ( + "23e64aec", + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": ["cloud.cloud"], + "failed_agent_ids": [], + "with_strategy_settings": False, + }, + ), + ( + "12345", + None, + ), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, + mock_list_files: Mock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + mock_list_files.assert_called_once_with(cloud, storage_type="backup") + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_get_download_details: Mock, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "23e64aec" + + aioclient_mock.get( + mock_get_download_details.return_value["url"], content=b"backup data" + ) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_fail_cloud( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_get_download_details: Mock, + side_effect: Exception, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "23e64aec" + mock_get_download_details.side_effect = side_effect + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to get download details" in content.decode() + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_fail_get( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_get_download_details: Mock, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "23e64aec" + + aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to download backup" in content.decode() + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test agent download backup raises error if not found.""" + client = await hass_client() + backup_id = "1234" + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 404 + assert await resp.content.read() == b"" + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + mock_get_upload_details: Mock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0.0, + ) + aioclient_mock.put(mock_get_upload_details.return_value["url"]) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[-1][0] == "PUT" + assert aioclient_mock.mock_calls[-1][1] == URL( + mock_get_upload_details.return_value["url"] + ) + assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + + +@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_put( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + mock_get_upload_details: Mock, + put_mock_kwargs: dict[str, Any], +) -> None: + """Test agent upload backup fails.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0.0, + ) + aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Error during backup upload - Failed to upload backup" in caplog.text + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +@pytest.mark.usefixtures("cloud_logged_in") +async def test_agents_upload_fail_cloud( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_get_upload_details: Mock, + side_effect: Exception, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent upload backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "test-backup" + mock_get_upload_details.side_effect = side_effect + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0.0, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Error during backup upload - Failed to get upload details" in caplog.text + + +async def test_agents_upload_not_protected( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent upload backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + with ( + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Error during backup upload - Cloud backups must be protected" in caplog.text + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_delete_file: Mock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "23e64aec" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_delete_file.assert_called_once() + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_delete_fail_cloud( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_delete_file: Mock, + side_effect: Exception, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "23e64aec" + mock_delete_file.side_effect = side_effect + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"cloud.cloud": "Failed to delete backup"} + } + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent download backup raises error if not found.""" + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {"cloud.cloud": "Backup not found"}} diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 9218e7343ec..4159c8ec1a1 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 51bd946f140..3eab18fb9f3 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 58ce74035f9..877f48a4611 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -71,6 +71,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -135,6 +137,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index b96aa9ae006..4d37f3c871b 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -137,11 +137,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentries": [], "supports_options": True, "supports_reconfigure": False, "supports_remove_device": False, @@ -155,11 +157,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": core_ce.ConfigEntryState.SETUP_ERROR.value, + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -173,11 +177,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -191,11 +197,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -209,11 +217,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -255,9 +265,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None: """Test removing an entry via the API.""" - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) resp = await client.delete(f"/api/config/config_entries/entry/{entry.entry_id}") assert resp.status == HTTPStatus.OK @@ -268,11 +276,9 @@ async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None: async def test_reload_entry(hass: HomeAssistant, client: TestClient) -> None: """Test reloading an entry via the API.""" - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) - hass.config.components.add("kitchen_sink") + hass.config.components.add("test") resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -575,11 +581,13 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": core_ce.SOURCE_USER, "state": core_ce.ConfigEntryState.LOADED.value, + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -590,6 +598,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": [], } @@ -658,11 +667,13 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": core_ce.SOURCE_USER, "state": core_ce.ConfigEntryState.LOADED.value, + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -673,6 +684,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": [], } @@ -1092,6 +1104,273 @@ async def test_options_flow_with_invalid_data( assert data == {"errors": {"choices": "invalid is not a valid option"}} +async def test_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can start a subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + schema = OrderedDict() + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries(cls, config_entry): + return ("test",) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "user", + "data_schema": [{"name": "enabled", "required": True, "type": "boolean"}], + "description_placeholders": {"enabled": "Set to true to be true"}, + "errors": None, + "last_step": None, + "preview": None, + } + + +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/api/config/config_entries/subentries/flow", "post"), + ("/api/config/config_entries/subentries/flow/1", "get"), + ("/api/config/config_entries/subentries/flow/1", "post"), + ], +) +async def test_subentry_flow_unauth( + hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str +) -> None: + """Test unauthorized on subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries(cls, config_entry): + return ("test",) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + hass_admin_user.groups = [] + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.UNAUTHORIZED + + +async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can finish a two step subentry flow.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return await self.async_step_finish() + + async def async_step_finish(self, user_input=None): + if user_input: + return self.async_create_entry( + title="Mock title", data=user_input, unique_id="test" + ) + + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) + ) + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries(cls, config_entry): + return ("test",) + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data["flow_id"] + expected_data = { + "data_schema": [{"name": "enabled", "type": "boolean"}], + "description_placeholders": None, + "errors": None, + "flow_id": flow_id, + "handler": ["test1", "test"], + "last_step": None, + "preview": None, + "step_id": "finish", + "type": "form", + } + assert data == expected_data + + resp = await client.get(f"/api/config/config_entries/subentries/flow/{flow_id}") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == expected_data + + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "description_placeholders": None, + "description": None, + "flow_id": flow_id, + "handler": ["test1", "test"], + "title": "Mock title", + "type": "create_entry", + "unique_id": "test", + } + + +async def test_subentry_flow_with_invalid_data(hass: HomeAssistant, client) -> None: + """Test a subentry flow with invalid_data.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="finish", + data_schema=vol.Schema( + { + vol.Required( + "choices", default=["invalid", "valid"] + ): cv.multi_select({"valid": "Valid"}) + } + ), + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title="Enable disable", data=user_input + ) + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries(cls, config_entry): + return ("test",) + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "finish", + "data_schema": [ + { + "default": ["invalid", "valid"], + "name": "choices", + "options": {"valid": "Valid"}, + "required": True, + "type": "multi_select", + } + ], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"choices": ["valid", "invalid"]}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == {"errors": {"choices": "invalid is not a valid option"}} + + @pytest.mark.usefixtures("freezer") async def test_get_single( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1124,11 +1403,13 @@ async def test_get_single( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "user", "state": "loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1157,11 +1438,9 @@ async def test_update_prefrences( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) - hass.config.components.add("kitchen_sink") + hass.config.components.add("test") assert entry.pref_disable_new_entities is False assert entry.pref_disable_polling is False @@ -1257,12 +1536,10 @@ async def test_disable_entry( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) assert entry.disabled_by is None - hass.config.components.add("kitchen_sink") + hass.config.components.add("test") # Disable await ws_client.send_json( @@ -1488,11 +1765,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1507,11 +1786,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1526,11 +1807,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1545,11 +1828,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1564,11 +1849,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1594,11 +1881,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1623,11 +1912,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1642,11 +1933,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1671,11 +1964,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1690,11 +1985,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1725,11 +2022,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1744,11 +2043,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1763,11 +2064,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1782,11 +2085,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1801,11 +2106,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1908,11 +2215,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1930,11 +2239,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1952,11 +2263,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1980,11 +2293,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2009,11 +2324,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2037,11 +2354,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": entry.modified_at.timestamp(), + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2127,11 +2446,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2149,11 +2470,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2179,11 +2502,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2205,11 +2530,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2235,11 +2562,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2263,11 +2592,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": entry.modified_at.timestamp(), + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentries": [], "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2478,3 +2809,133 @@ async def test_does_not_support_reconfigure( response == '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}' ) + + +async def test_list_subentries( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can list subentries.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries_data=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, + subentry_id="mock_id", + title="Mock title", + unique_id="test", + ) + ], + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == [ + {"subentry_id": "mock_id", "title": "Mock title", "unique_id": "test"}, + ] + + # Try listing subentries for an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": "no_such_entry", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + + +async def test_delete_subentry( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can delete a subentry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries_data=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, subentry_id="mock_id", title="Mock title" + ) + ], + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] is None + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == [] + + # Try deleting the subentry again + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config subentry not found", + } + + # Try deleting subentry from an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": "no_such_entry", + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 97b1d337e82..3828cc5ff37 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Generator +from functools import lru_cache from importlib.util import find_spec from pathlib import Path import string @@ -37,6 +38,7 @@ from homeassistant.data_entry_flow import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations +from homeassistant.util import yaml if TYPE_CHECKING: from homeassistant.components.hassio import AddonManager @@ -512,11 +514,14 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" + mounts_info_mock = AsyncMock(spec_set=["mounts"]) + mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() supervisor_client.supervisor = AsyncMock() @@ -533,6 +538,10 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.addon_manager.get_supervisor_client", return_value=supervisor_client, ), + patch( + "homeassistant.components.hassio.backup.get_supervisor_client", + return_value=supervisor_client, + ), patch( "homeassistant.components.hassio.discovery.get_supervisor_client", return_value=supervisor_client, @@ -615,6 +624,26 @@ def ignore_translations() -> str | list[str]: return [] +@lru_cache +def _get_integration_quality_scale(integration: str) -> dict[str, Any]: + """Get the quality scale for an integration.""" + try: + return yaml.load_yaml_dict( + f"homeassistant/components/{integration}/quality_scale.yaml" + ).get("rules", {}) + except FileNotFoundError: + return {} + + +def _get_integration_quality_scale_rule(integration: str, rule: str) -> str: + """Get the quality scale for an integration.""" + quality_scale = _get_integration_quality_scale(integration) + if not quality_scale or rule not in quality_scale: + return "todo" + status = quality_scale[rule] + return status if isinstance(status, str) else status["status"] + + async def _check_config_flow_result_translations( manager: FlowManager, flow: FlowHandler, @@ -646,6 +675,9 @@ async def _check_config_flow_result_translations( setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) if result["type"] is FlowResultType.FORM: + iqs_config_flow = _get_integration_quality_scale_rule( + integration, "config-flow" + ) if step_id := result.get("step_id"): # neither title nor description are required # - title defaults to integration name @@ -660,6 +692,19 @@ async def _check_config_flow_result_translations( result["description_placeholders"], translation_required=False, ) + if iqs_config_flow == "done" and (data_schema := result["data_schema"]): + # data and data_description are compulsory + for data_key in data_schema.schema: + for header in ("data", "data_description"): + await _validate_translation( + flow.hass, + translation_errors, + category, + integration, + f"{key_prefix}step.{step_id}.{header}.{data_key}", + result["description_placeholders"], + ) + if errors := result.get("errors"): for error in errors.values(): await _validate_translation( diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index a3edd4fa51c..9cebfd9abd1 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -571,7 +571,7 @@ 'name': 'HassGetState', }), 'match': True, - 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', + 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} []', 'slots': dict({ 'area': 'kitchen', 'domain': 'lights', @@ -693,6 +693,14 @@ }) # --- # name: test_ws_hass_agent_debug_sentence_trigger + dict({ + 'trigger_sentences': list([ + 'hello', + 'hello[ world]', + ]), + }) +# --- +# name: test_ws_hass_agent_debug_sentence_trigger.1 dict({ 'results': list([ dict({ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index dab1e61ab81..8df1647d18c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -30,6 +30,7 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, STATE_CLOSED, + STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory, @@ -3013,3 +3014,85 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: assert len(name_list.values) == 2 assert name_list.values[0].text_in.text == "test light" assert name_list.values[1].text_in.text == "test light" + + +@pytest.mark.usefixtures("init_components") +async def test_entities_names_are_not_templates(hass: HomeAssistant) -> None: + """Test that entities names are not treated as hassil templates.""" + # Contains hassil template characters + hass.states.async_set( + "light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: " None: + """Test turn on/off in multiple languages.""" + entity_id = "light.light1234" + hass.states.async_set( + entity_id, STATE_OFF, attributes={ATTR_FRIENDLY_NAME: light_name} + ) + + on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + await conversation.async_converse( + hass, + on_sentence, + None, + Context(), + language=language, + ) + assert len(on_calls) == 1 + assert on_calls[0].data.get("entity_id") == [entity_id] + + off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") + await conversation.async_converse( + hass, + off_sentence, + None, + Context(), + language=language, + ) + assert len(off_calls) == 1 + assert off_calls[0].data.get("entity_id") == [entity_id] diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index e792d8c6913..6d69ec3c739 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -501,6 +501,19 @@ async def test_ws_hass_agent_debug_sentence_trigger( client = await hass_ws_client(hass) + # List sentence + await client.send_json_auto_id( + { + "type": "conversation/sentences/list", + } + ) + await hass.async_block_till_done() + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + # Use trigger sentence await client.send_json_auto_id( { diff --git a/tests/components/cookidoo/__init__.py b/tests/components/cookidoo/__init__.py new file mode 100644 index 00000000000..043f627ecc6 --- /dev/null +++ b/tests/components/cookidoo/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Cookidoo integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the cookidoo integration.""" + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py new file mode 100644 index 00000000000..68700967d35 --- /dev/null +++ b/tests/components/cookidoo/conftest.py @@ -0,0 +1,76 @@ +"""Common fixtures for the Cookidoo tests.""" + +from collections.abc import Generator +from typing import cast +from unittest.mock import AsyncMock, patch + +from cookidoo_api import ( + CookidooAdditionalItem, + CookidooAuthResponse, + CookidooIngredientItem, +) +import pytest + +from homeassistant.components.cookidoo.const import DOMAIN +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD + +from tests.common import MockConfigEntry, load_json_object_fixture + +EMAIL = "test-email" +PASSWORD = "test-password" +COUNTRY = "CH" +LANGUAGE = "de-CH" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cookidoo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_cookidoo_client() -> Generator[AsyncMock]: + """Mock a Cookidoo client.""" + with ( + patch( + "homeassistant.components.cookidoo.Cookidoo", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.cookidoo.config_flow.Cookidoo", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"}) + client.get_ingredient_items.return_value = [ + CookidooIngredientItem(**item) + for item in load_json_object_fixture("ingredient_items.json", DOMAIN)[ + "data" + ] + ] + client.get_additional_items.return_value = [ + CookidooAdditionalItem(**item) + for item in load_json_object_fixture("additional_items.json", DOMAIN)[ + "data" + ] + ] + yield client + + +@pytest.fixture(name="cookidoo_config_entry") +def mock_cookidoo_config_entry() -> MockConfigEntry: + """Mock cookidoo configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + }, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + ) diff --git a/tests/components/cookidoo/fixtures/additional_items.json b/tests/components/cookidoo/fixtures/additional_items.json new file mode 100644 index 00000000000..97cd206f6ad --- /dev/null +++ b/tests/components/cookidoo/fixtures/additional_items.json @@ -0,0 +1,9 @@ +{ + "data": [ + { + "id": "unique_id_tomaten", + "name": "Tomaten", + "is_owned": false + } + ] +} diff --git a/tests/components/cookidoo/fixtures/ingredient_items.json b/tests/components/cookidoo/fixtures/ingredient_items.json new file mode 100644 index 00000000000..7fbeb90e91a --- /dev/null +++ b/tests/components/cookidoo/fixtures/ingredient_items.json @@ -0,0 +1,10 @@ +{ + "data": [ + { + "id": "unique_id_mehl", + "name": "Mehl", + "description": "200 g", + "is_owned": false + } + ] +} diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr new file mode 100644 index 00000000000..965cbb0adde --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_todo[todo.cookidoo_additional_purchases-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'todo', + 'entity_category': None, + 'entity_id': 'todo.cookidoo_additional_purchases', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Additional purchases', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'additional_item_list', + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items', + 'unit_of_measurement': None, + }) +# --- +# name: test_todo[todo.cookidoo_additional_purchases-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Additional purchases', + 'supported_features': , + }), + 'context': , + 'entity_id': 'todo.cookidoo_additional_purchases', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_todo[todo.cookidoo_shopping_list-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'todo', + 'entity_category': None, + 'entity_id': 'todo.cookidoo_shopping_list', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Shopping list', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ingredient_list', + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_ingredients', + 'unit_of_measurement': None, + }) +# --- +# name: test_todo[todo.cookidoo_shopping_list-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Shopping list', + 'supported_features': , + }), + 'context': , + 'entity_id': 'todo.cookidoo_shopping_list', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py new file mode 100644 index 00000000000..cfdc284dbfe --- /dev/null +++ b/tests/components/cookidoo/test_config_flow.py @@ -0,0 +1,306 @@ +"""Test the Cookidoo config flow.""" + +from unittest.mock import AsyncMock + +from cookidoo_api.exceptions import ( + CookidooAuthException, + CookidooException, + CookidooRequestException, +) +import pytest + +from homeassistant.components.cookidoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD + +from tests.common import MockConfigEntry + +MOCK_DATA_USER_STEP = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, +} + +MOCK_DATA_LANGUAGE_STEP = { + CONF_LANGUAGE: LANGUAGE, +} + + +async def test_flow_user_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_cookidoo_client: AsyncMock +) -> None: + """Test we get the user flow and create entry with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "cookidoo" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Cookidoo" + assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooAuthException(), "invalid_auth"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + mock_cookidoo_client.login.side_effect = raise_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == "Cookidoo" + + assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooAuthException(), "invalid_auth"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover_on_step_2( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + mock_cookidoo_client.get_additional_items.side_effect = raise_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_cookidoo_client.get_additional_items.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == "Cookidoo" + + assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + + +async def test_flow_user_init_data_already_configured( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort user data set when entry is already configured.""" + + cookidoo_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_reauth( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + + cookidoo_config_entry.add_to_hass(hass) + + result = await cookidoo_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert cookidoo_config_entry.data == { + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooAuthException(), "invalid_auth"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reauth_error_and_recover( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + raise_error, + text_error, +) -> None: + """Test reauth flow.""" + + cookidoo_config_entry.add_to_hass(hass) + + result = await cookidoo_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_cookidoo_client.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert cookidoo_config_entry.data == { + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("new_email", "saved_email", "result_reason"), + [ + (EMAIL, EMAIL, "reauth_successful"), + ("another-email", EMAIL, "already_configured"), + ], +) +async def test_flow_reauth_init_data_already_configured( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + new_email: str, + saved_email: str, + result_reason: str, +) -> None: + """Test we abort user data set when entry is already configured.""" + + cookidoo_config_entry.add_to_hass(hass) + + another_cookidoo_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "another-email", + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + }, + ) + + another_cookidoo_config_entry.add_to_hass(hass) + + result = await cookidoo_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: new_email, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == result_reason + assert cookidoo_config_entry.data[CONF_EMAIL] == saved_email diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py new file mode 100644 index 00000000000..b1b9b880526 --- /dev/null +++ b/tests/components/cookidoo/test_init.py @@ -0,0 +1,102 @@ +"""Unit tests for the cookidoo integration.""" + +from unittest.mock import AsyncMock + +from cookidoo_api import CookidooAuthException, CookidooRequestException +import pytest + +from homeassistant.components.cookidoo.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_load_unload( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading of the config entry.""" + await setup_integration(hass, cookidoo_config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(cookidoo_config_entry.entry_id) + assert cookidoo_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (CookidooRequestException, ConfigEntryState.SETUP_RETRY), + (CookidooAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_init_failure( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + status: ConfigEntryState, + exception: Exception, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test an initialization error on integration load.""" + mock_cookidoo_client.login.side_effect = exception + await setup_integration(hass, cookidoo_config_entry) + assert cookidoo_config_entry.state == status + + +@pytest.mark.parametrize( + "cookidoo_method", + [ + "get_ingredient_items", + "get_additional_items", + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + cookidoo_method: str, +) -> None: + """Test config entry not ready.""" + getattr( + mock_cookidoo_client, cookidoo_method + ).side_effect = CookidooRequestException() + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (None, ConfigEntryState.LOADED), + (CookidooRequestException, ConfigEntryState.SETUP_RETRY), + (CookidooAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_config_entry_not_ready_auth_error( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + exception: Exception | None, + status: ConfigEntryState, +) -> None: + """Test config entry not ready from authentication error.""" + + mock_cookidoo_client.get_ingredient_items.side_effect = CookidooAuthException + mock_cookidoo_client.refresh_token.side_effect = exception + + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is status diff --git a/tests/components/cookidoo/test_todo.py b/tests/components/cookidoo/test_todo.py new file mode 100644 index 00000000000..0e60a86d225 --- /dev/null +++ b/tests/components/cookidoo/test_todo.py @@ -0,0 +1,292 @@ +"""Test for todo platform of the Cookidoo integration.""" + +from collections.abc import Generator +import re +from unittest.mock import AsyncMock, patch + +from cookidoo_api import ( + CookidooAdditionalItem, + CookidooIngredientItem, + CookidooRequestException, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.todo import ( + ATTR_ITEM, + ATTR_RENAME, + ATTR_STATUS, + DOMAIN as TODO_DOMAIN, + TodoItemStatus, + TodoServices, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def todo_only() -> Generator[None]: + """Enable only the todo platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.TODO], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_todo( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of todo platform.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) + + +async def test_update_ingredient( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update ingredient item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_mehl", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_shopping_list"}, + blocking=True, + ) + + mock_cookidoo_client.edit_ingredient_items_ownership.assert_called_once_with( + [ + CookidooIngredientItem( + id="unique_id_mehl", + name="", + description="", + is_owned=True, + ) + ], + ) + + +async def test_update_ingredient_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update ingredient with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.edit_ingredient_items_ownership.side_effect = ( + CookidooRequestException + ) + with pytest.raises( + HomeAssistantError, match="Failed to update Mehl in Cookidoo shopping list" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_mehl", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_shopping_list"}, + blocking=True, + ) + + +async def test_add_additional_item( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test add additional item to list.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + service_data={ATTR_ITEM: "Äpfel"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + mock_cookidoo_client.add_additional_items.assert_called_once_with( + ["Äpfel"], + ) + + +async def test_add_additional_item_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test add additional item to list with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.add_additional_items.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, match="Failed to save Äpfel to Cookidoo shopping list" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + service_data={ATTR_ITEM: "Äpfel"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + +async def test_update_additional_item( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update additional item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_tomaten", + ATTR_RENAME: "Peperoni", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + mock_cookidoo_client.edit_additional_items_ownership.assert_called_once_with( + [ + CookidooAdditionalItem( + id="unique_id_tomaten", + name="Peperoni", + is_owned=True, + ) + ], + ) + mock_cookidoo_client.edit_additional_items.assert_called_once_with( + [ + CookidooAdditionalItem( + id="unique_id_tomaten", + name="Peperoni", + is_owned=True, + ) + ], + ) + + +async def test_update_additional_item_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update additional item with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.edit_additional_items_ownership.side_effect = ( + CookidooRequestException + ) + mock_cookidoo_client.edit_additional_items.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, match="Failed to update Peperoni in Cookidoo shopping list" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_tomaten", + ATTR_RENAME: "Peperoni", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + +async def test_delete_additional_items( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test delete additional item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + service_data={ATTR_ITEM: "unique_id_tomaten"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + mock_cookidoo_client.remove_additional_items.assert_called_once_with( + ["unique_id_tomaten"] + ) + + +async def test_delete_additional_items_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test delete additional item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + mock_cookidoo_client.remove_additional_items.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, + match=re.escape("Failed to delete 1 item(s) from Cookidoo shopping list"), + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + service_data={ATTR_ITEM: "unique_id_tomaten"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 646c44e4ac2..e43b64b16a7 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,8 +2,6 @@ from enum import Enum -import pytest - from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -155,20 +153,3 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockCoverEntity(cover.CoverEntity): - _attr_supported_features = 1 - - entity = MockCoverEntity() - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "MockCoverEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CoverEntityFeature.OPEN" in caplog.text - caplog.clear() - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/deako/test_init.py b/tests/components/deako/test_init.py index b4c0e8bb1f7..c2291330feb 100644 --- a/tests/components/deako/test_init.py +++ b/tests/components/deako/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from pydeako.deako import DeviceListTimeout, FindDevicesTimeout +from pydeako import FindDevicesError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -37,7 +37,7 @@ async def test_deako_async_setup_entry( assert mock_config_entry.runtime_data == pydeako_deako_mock.return_value -async def test_deako_async_setup_entry_device_list_timeout( +async def test_deako_async_setup_entry_devices_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, pydeako_deako_mock: MagicMock, @@ -47,32 +47,7 @@ async def test_deako_async_setup_entry_device_list_timeout( mock_config_entry.add_to_hass(hass) - pydeako_deako_mock.return_value.find_devices.side_effect = DeviceListTimeout() - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - pydeako_deako_mock.assert_called_once_with( - pydeako_discoverer_mock.return_value.get_address - ) - pydeako_deako_mock.return_value.connect.assert_called_once() - pydeako_deako_mock.return_value.find_devices.assert_called_once() - pydeako_deako_mock.return_value.disconnect.assert_called_once() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_deako_async_setup_entry_find_devices_timeout( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - pydeako_deako_mock: MagicMock, - pydeako_discoverer_mock: MagicMock, -) -> None: - """Test async_setup_entry raises ConfigEntryNotReady when pydeako raises FindDevicesTimeout.""" - - mock_config_entry.add_to_hass(hass) - - pydeako_deako_mock.return_value.find_devices.side_effect = FindDevicesTimeout() + pydeako_deako_mock.return_value.find_devices.side_effect = FindDevicesError() await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index 1ca674a4fbe..20558b4bbbd 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 15135a333ce..9ac15d4867b 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -11,7 +11,7 @@ from homeassistant.components.deconz.const import CONF_ALLOW_DECONZ_GROUPS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -391,7 +391,7 @@ async def test_light_state_change( "call": { ATTR_ENTITY_ID: "light.hue_go", ATTR_BRIGHTNESS: 200, - ATTR_COLOR_TEMP: 200, + ATTR_COLOR_TEMP_KELVIN: 5000, ATTR_TRANSITION: 5, ATTR_FLASH: FLASH_SHORT, ATTR_EFFECT: EFFECT_COLORLOOP, @@ -804,7 +804,7 @@ async def test_groups( "call": { ATTR_ENTITY_ID: "light.group", ATTR_BRIGHTNESS: 200, - ATTR_COLOR_TEMP: 200, + ATTR_COLOR_TEMP_KELVIN: 5000, ATTR_TRANSITION: 5, ATTR_FLASH: FLASH_SHORT, ATTR_EFFECT: EFFECT_COLORLOOP, @@ -1079,7 +1079,7 @@ async def test_non_color_light_reports_color( hass.states.get("light.group").attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP ) - assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP] == 250 + assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 # Updating a scene will return a faulty color value # for a non-color light causing an exception in hs_color @@ -1099,7 +1099,7 @@ async def test_non_color_light_reports_color( group = hass.states.get("light.group") assert group.attributes[ATTR_COLOR_MODE] == ColorMode.XY assert group.attributes[ATTR_HS_COLOR] == (40.571, 41.176) - assert group.attributes.get(ATTR_COLOR_TEMP) is None + assert group.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None @pytest.mark.parametrize( diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index 8fcdb8a9c2e..b39b09d9307 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -9,11 +9,10 @@ from homeassistant.components.demo import DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, - ATTR_KELVIN, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, @@ -79,25 +78,33 @@ async def test_state_attributes(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_EFFECT: "none", ATTR_COLOR_TEMP: 400}, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_EFFECT: "none", + ATTR_COLOR_TEMP_KELVIN: 2500, + }, blocking=True, ) state = hass.states.get(ENTITY_LIGHT) - assert state.attributes.get(ATTR_COLOR_TEMP) == 400 - assert state.attributes.get(ATTR_MIN_MIREDS) == 153 - assert state.attributes.get(ATTR_MAX_MIREDS) == 500 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2500 + assert state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) == 6535 + assert state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) == 2000 assert state.attributes.get(ATTR_EFFECT) == "none" await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS_PCT: 50, ATTR_KELVIN: 3000}, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS_PCT: 50, + ATTR_COLOR_TEMP_KELVIN: 3000, + }, blocking=True, ) state = hass.states.get(ENTITY_LIGHT) - assert state.attributes.get(ATTR_COLOR_TEMP) == 333 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 3000 assert state.attributes.get(ATTR_BRIGHTNESS) == 128 diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index a4e4d6f0e1f..f910e6e53ac 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -22,11 +22,7 @@ from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -75,35 +71,35 @@ async def test_supported_features(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MOST) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12412 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_BASIC) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12360 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MINIMAL) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_NONE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED async def test_methods(hass: HomeAssistant) -> None: @@ -111,29 +107,29 @@ async def test_methods(hass: HomeAssistant) -> None: await common.async_start(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING await common.async_stop(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.state == STATE_IDLE + assert state.state == VacuumActivity.IDLE state = hass.states.get(ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED await async_setup_component(hass, "notify", {}) await hass.async_block_till_done() await common.async_locate(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_IDLE + assert state.state == VacuumActivity.IDLE await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_RETURNING + assert state.state == VacuumActivity.RETURNING await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_COMPLETE @@ -145,21 +141,21 @@ async def test_methods(hass: HomeAssistant) -> None: await common.async_clean_spot(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_PAUSED + assert state.state == VacuumActivity.PAUSED await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_RETURNING + assert state.state == VacuumActivity.RETURNING async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED async def test_unsupported_methods(hass: HomeAssistant) -> None: @@ -251,4 +247,4 @@ async def test_send_command(hass: HomeAssistant) -> None: new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) assert old_state_complete != new_state_complete - assert new_state_complete.state == STATE_IDLE + assert new_state_complete.state == VacuumActivity.IDLE diff --git a/tests/components/devolo_home_control/__init__.py b/tests/components/devolo_home_control/__init__.py index f0e18eaf1a2..a1bf9d56aac 100644 --- a/tests/components/devolo_home_control/__init__.py +++ b/tests/components/devolo_home_control/__init__.py @@ -11,7 +11,6 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: config = { "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", } entry = MockConfigEntry( domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index 6a7ef1fc6d3..0e507ca0b28 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -33,7 +33,6 @@ ]), 'entry': dict({ 'data': dict({ - 'mydevolo_url': 'https://test_mydevolo_url.test', 'password': '**REDACTED**', 'username': '**REDACTED**', }), @@ -48,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '123456', 'version': 1, diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index 7c9bfdeff63..aab3e69b38f 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.devolo_home_control.const import DEFAULT_MYDEVOLO, DOMAIN +from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -90,7 +90,6 @@ async def test_form_advanced_options(hass: HomeAssistant) -> None: { "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) await hass.async_block_till_done() @@ -100,7 +99,6 @@ async def test_form_advanced_options(hass: HomeAssistant) -> None: assert result2["data"] == { "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", } assert len(mock_setup_entry.mock_calls) == 1 @@ -170,7 +168,6 @@ async def test_form_reauth(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) mock_config.add_to_hass(hass) @@ -207,7 +204,6 @@ async def test_form_invalid_credentials_reauth(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) mock_config.add_to_hass(hass) @@ -229,7 +225,6 @@ async def test_form_uuid_change_reauth(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) mock_config.add_to_hass(hass) @@ -281,7 +276,6 @@ async def _setup(hass: HomeAssistant, result: FlowResult) -> None: assert result2["data"] == { "username": "test-username", "password": "test-password", - "mydevolo_url": DEFAULT_MYDEVOLO, } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py index 05ccbca0c56..f6d1c13299a 100644 --- a/tests/components/devolo_home_network/__init__.py +++ b/tests/components/devolo_home_network/__init__.py @@ -4,7 +4,7 @@ from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import IP +from .const import DISCOVERY_INFO, IP from tests.common import MockConfigEntry @@ -15,7 +15,12 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: CONF_IP_ADDRESS: IP, CONF_PASSWORD: "test", } - entry = MockConfigEntry(domain=DOMAIN, data=config, entry_id="123456") + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + entry_id="123456", + unique_id=DISCOVERY_INFO.properties["SN"], + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 3da8c76c2b4..1288b7f3ef6 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -32,8 +32,10 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', - 'unique_id': None, + 'unique_id': '1234567890', 'version': 1, }), }) diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 5234d0f073e..92163b5cb95 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -29,8 +29,6 @@ from .const import ( ) from .mock import MockDevice -from tests.common import MockConfigEntry - async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: """Test we get the form.""" @@ -125,6 +123,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: CONF_IP_ADDRESS: IP, CONF_PASSWORD: "", } + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["result"].unique_id == "1234567890" async def test_abort_zeroconf_wrong_device(hass: HomeAssistant) -> None: @@ -139,13 +139,9 @@ async def test_abort_zeroconf_wrong_device(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("info") -async def test_abort_if_configued(hass: HomeAssistant) -> None: +async def test_abort_if_configured(hass: HomeAssistant) -> None: """Test we abort config flow if already configured.""" - serial_number = DISCOVERY_INFO.properties["SN"] - entry = MockConfigEntry( - domain=DOMAIN, unique_id=serial_number, data={CONF_IP_ADDRESS: IP} - ) - entry.add_to_hass(hass) + entry = configure_integration(hass) # Abort on concurrent user flow result = await hass.config_entries.flow.async_init( diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 7f70524fa5b..4fe7a173309 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -141,7 +141,7 @@ async def test_device_failure_update( async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: - """Test updating unautherized triggers the reauth flow.""" + """Test updating unauthorized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{PLATFORM}.{device_name}_firmware" diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr index d407fe2dc5b..0a46dd7f476 100644 --- a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'dsmr_reader', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/dte_energy_bridge/__init__.py b/tests/components/dte_energy_bridge/__init__.py deleted file mode 100644 index 615944bda88..00000000000 --- a/tests/components/dte_energy_bridge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the dte_energy_bridge component.""" diff --git a/tests/components/dte_energy_bridge/test_sensor.py b/tests/components/dte_energy_bridge/test_sensor.py deleted file mode 100644 index 41d340fae48..00000000000 --- a/tests/components/dte_energy_bridge/test_sensor.py +++ /dev/null @@ -1,58 +0,0 @@ -"""The tests for the DTE Energy Bridge.""" - -import requests_mock - -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -DTE_ENERGY_BRIDGE_CONFIG = {"platform": "dte_energy_bridge", "ip": "192.168.1.1"} - - -async def test_setup_with_config(hass: HomeAssistant) -> None: - """Test the platform setup with configuration.""" - assert await async_setup_component( - hass, "sensor", {"dte_energy_bridge": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - - -async def test_setup_correct_reading(hass: HomeAssistant) -> None: - """Test DTE Energy bridge returns a correct value.""" - with requests_mock.Mocker() as mock_req: - mock_req.get( - f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", - text=".411 kW", - ) - assert await async_setup_component( - hass, "sensor", {"sensor": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - assert hass.states.get("sensor.current_energy_usage").state == "0.411" - - -async def test_setup_incorrect_units_reading(hass: HomeAssistant) -> None: - """Test DTE Energy bridge handles a value with incorrect units.""" - with requests_mock.Mocker() as mock_req: - mock_req.get( - f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", - text="411 kW", - ) - assert await async_setup_component( - hass, "sensor", {"sensor": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - assert hass.states.get("sensor.current_energy_usage").state == "0.411" - - -async def test_setup_bad_format_reading(hass: HomeAssistant) -> None: - """Test DTE Energy bridge handles an invalid value.""" - with requests_mock.Mocker() as mock_req: - mock_req.get( - f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", - text="411", - ) - assert await async_setup_component( - hass, "sensor", {"sensor": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - assert hass.states.get("sensor.current_energy_usage").state == "unknown" diff --git a/tests/components/easyenergy/test_init.py b/tests/components/easyenergy/test_init.py index 74293049fd1..c3c917bc9ed 100644 --- a/tests/components/easyenergy/test_init.py +++ b/tests/components/easyenergy/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch from easyenergy import EasyEnergyConnectionError -from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,7 +23,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index 38c8a9a5ab9..f9540e06038 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, @@ -70,6 +72,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/eheimdigital/__init__.py b/tests/components/eheimdigital/__init__.py new file mode 100644 index 00000000000..1f608f868de --- /dev/null +++ b/tests/components/eheimdigital/__init__.py @@ -0,0 +1 @@ +"""Tests for the EHEIM Digital integration.""" diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py new file mode 100644 index 00000000000..cdad628de6b --- /dev/null +++ b/tests/components/eheimdigital/conftest.py @@ -0,0 +1,58 @@ +"""Configurations for the EHEIM Digital tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.hub import EheimDigitalHub +from eheimdigital.types import EheimDeviceType, LightMode +import pytest + +from homeassistant.components.eheimdigital.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "eheimdigital"}, unique_id="00:00:00:00:00:01" + ) + + +@pytest.fixture +def classic_led_ctrl_mock(): + """Mock a classicLEDcontrol device.""" + classic_led_ctrl_mock = MagicMock(spec=EheimDigitalClassicLEDControl) + classic_led_ctrl_mock.tankconfig = [["CLASSIC_DAYLIGHT"], []] + classic_led_ctrl_mock.mac_address = "00:00:00:00:00:01" + classic_led_ctrl_mock.device_type = ( + EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + classic_led_ctrl_mock.name = "Mock classicLEDcontrol+e" + classic_led_ctrl_mock.aquarium_name = "Mock Aquarium" + classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE + classic_led_ctrl_mock.light_level = (10, 39) + return classic_led_ctrl_mock + + +@pytest.fixture +def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: + """Mock eheimdigital hub.""" + with ( + patch( + "homeassistant.components.eheimdigital.coordinator.EheimDigitalHub", + spec=EheimDigitalHub, + ) as eheimdigital_hub_mock, + patch( + "homeassistant.components.eheimdigital.config_flow.EheimDigitalHub", + new=eheimdigital_hub_mock, + ), + ): + eheimdigital_hub_mock.return_value.devices = { + "00:00:00:00:00:01": classic_led_ctrl_mock + } + eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock + yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_light.ambr b/tests/components/eheimdigital/snapshots/test_light.ambr new file mode 100644 index 00000000000..8df4745997e --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_light.ambr @@ -0,0 +1,316 @@ +# serializer version: 1 +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig0][light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig0][light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig1][light.mock_classicledcontrol_e_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 1', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig1][light.mock_classicledcontrol_e_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 99, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 1', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 99, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py new file mode 100644 index 00000000000..e75cf31eb98 --- /dev/null +++ b/tests/components/eheimdigital/test_config_flow.py @@ -0,0 +1,212 @@ +"""Tests the config flow of EHEIM Digital.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp import ClientConnectionError +import pytest + +from homeassistant.components.eheimdigital.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.0.2.1"), + ip_addresses=[ip_address("192.0.2.1")], + hostname="eheimdigital.local.", + name="eheimdigital._http._tcp.local.", + port=80, + type="_http._tcp.local.", + properties={}, +) + +USER_INPUT = {CONF_HOST: "eheimdigital"} + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_full_flow(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +async def test_flow_errors( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + side_effect: BaseException, + error_value: str, +) -> None: + """Test flow errors.""" + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_value} + + eheimdigital_hub_mock.return_value.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_zeroconf_flow( + hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_DISCOVERY.host + assert result["data"] == { + CONF_HOST: ZEROCONF_DISCOVERY.host, + } + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + side_effect: BaseException, + error_value: str, +) -> None: + """Test zeroconf flow errors.""" + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_value + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_abort(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> None: + """Test flow abort on matching data or unique_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HOST: "eheimdigital2"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py new file mode 100644 index 00000000000..211a8b3b6fd --- /dev/null +++ b/tests/components/eheimdigital/test_init.py @@ -0,0 +1,55 @@ +"""Tests for the init module.""" + +from unittest.mock import MagicMock + +from eheimdigital.types import EheimDeviceType + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_remove_device( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test removing a device.""" + assert await async_setup_component(hass, "config", {}) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + mac_address: str = eheimdigital_hub_mock.return_value.main.mac_address + + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + ) + assert device_entry is not None + + hass_client = await hass_ws_client(hass) + + # Do not allow to delete a connected device + response = await hass_client.remove_device( + device_entry.id, mock_config_entry.entry_id + ) + assert not response["success"] + + eheimdigital_hub_mock.return_value.devices = {} + + # Allow to delete a not connected device + response = await hass_client.remove_device( + device_entry.id, mock_config_entry.entry_id + ) + assert response["success"] diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py new file mode 100644 index 00000000000..da224979c43 --- /dev/null +++ b/tests/components/eheimdigital/test_light.py @@ -0,0 +1,249 @@ +"""Tests for the light module.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from aiohttp import ClientError +from eheimdigital.types import EheimDeviceType, LightMode +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.eheimdigital.const import EFFECT_DAYCL_MODE +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.color import value_to_brightness + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.parametrize( + "tankconfig", + [ + [["CLASSIC_DAYLIGHT"], []], + [[], ["CLASSIC_DAYLIGHT"]], + [["CLASSIC_DAYLIGHT"], ["CLASSIC_DAYLIGHT"]], + ], +) +async def test_setup_classic_led_ctrl( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + tankconfig: list[list[str]], + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test light platform setup with different channels.""" + mock_config_entry.add_to_hass(hass) + + classic_led_ctrl_mock.tankconfig = tankconfig + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + classic_led_ctrl_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = { + "00:00:00:00:00:01": classic_led_ctrl_mock + } + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("eheimdigital_hub_mock") +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test turning off the light.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_config_entry.runtime_data._async_device_found( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0"}, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) + classic_led_ctrl_mock.turn_off.assert_awaited_once_with(0) + + +@pytest.mark.parametrize( + ("dim_input", "expected_dim_value"), + [ + (3, 1), + (255, 100), + (128, 50), + ], +) +async def test_turn_on_brightness( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, + dim_input: int, + expected_dim_value: int, +) -> None: + """Test turning on the light with different brightness values.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_BRIGHTNESS: dim_input, + }, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) + classic_led_ctrl_mock.turn_on.assert_awaited_once_with(expected_dim_value, 0) + + +async def test_turn_on_effect( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test turning on the light with an effect value.""" + mock_config_entry.add_to_hass(hass) + + classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_EFFECT: EFFECT_DAYCL_MODE, + }, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.DAYCL_MODE) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test the light state update.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + classic_led_ctrl_mock.light_level = (20, 30) + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("light.mock_classicledcontrol_e_channel_0")) + assert state.attributes["brightness"] == value_to_brightness((1, 100), 20) + + +async def test_update_failed( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test an failed update.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + eheimdigital_hub_mock.return_value.update.side_effect = ClientError + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("light.mock_classicledcontrol_e_channel_0").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index c4d9a87b5ad..c9ed49ba13c 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -31,7 +31,7 @@ def mock_async_client() -> Generator[AsyncMock]: client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) client_mock.models.get_all.return_value = MOCK_MODELS with patch( - "elevenlabs.client.AsyncElevenLabs", return_value=client_mock + "elevenlabs.AsyncElevenLabs", return_value=client_mock ) as mock_async_client: yield mock_async_client diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index 73b09421576..afa89f8eb27 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -7,7 +7,7 @@ from elgato import BatteryInfo, ElgatoNoBatteryError, Info, Settings, State import pytest from homeassistant.components.elgato.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_fixture @@ -35,7 +35,6 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_HOST: "127.0.0.1", CONF_MAC: "AA:BB:CC:DD:EE:FF", - CONF_PORT: 9123, }, unique_id="CN11A1A00001", ) diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr deleted file mode 100644 index d5d005cff9c..00000000000 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,134 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow_implementation - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': 'CN11A1A00001', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'mac': None, - 'port': 9123, - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'elgato', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'mac': None, - 'port': 9123, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'elgato', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'CN11A1A00001', - 'unique_id': 'CN11A1A00001', - 'version': 1, - }), - 'title': 'CN11A1A00001', - 'type': , - 'version': 1, - }) -# --- -# name: test_full_zeroconf_flow_implementation - FlowResultSnapshot({ - 'context': dict({ - 'confirm_only': True, - 'source': 'zeroconf', - 'unique_id': 'CN11A1A00001', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'elgato', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'elgato', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'zeroconf', - 'title': 'CN11A1A00001', - 'unique_id': 'CN11A1A00001', - 'version': 1, - }), - 'title': 'CN11A1A00001', - 'type': , - 'version': 1, - }) -# --- -# name: test_zeroconf_during_onboarding - FlowResultSnapshot({ - 'context': dict({ - 'source': 'zeroconf', - 'unique_id': 'CN11A1A00001', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'elgato', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'elgato', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'zeroconf', - 'title': 'CN11A1A00001', - 'unique_id': 'CN11A1A00001', - 'version': 1, - }), - 'title': 'CN11A1A00001', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 009feefc145..4bb4644ab86 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -11,10 +11,10 @@ 27.316, 47.743, ), - 'max_color_temp_kelvin': 6993, + 'max_color_temp_kelvin': 7000, 'max_mireds': 344, - 'min_color_temp_kelvin': 2906, - 'min_mireds': 143, + 'min_color_temp_kelvin': 2900, + 'min_mireds': 142, 'rgb_color': tuple( 255, 189, @@ -43,10 +43,10 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6993, + 'max_color_temp_kelvin': 7000, 'max_mireds': 344, - 'min_color_temp_kelvin': 2906, - 'min_mireds': 143, + 'min_color_temp_kelvin': 2900, + 'min_mireds': 142, 'supported_color_modes': list([ , ]), @@ -126,9 +126,9 @@ 27.316, 47.743, ), - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'rgb_color': tuple( 255, @@ -159,9 +159,9 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'supported_color_modes': list([ , @@ -243,9 +243,9 @@ 358.0, 6.0, ), - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'rgb_color': tuple( 255, @@ -276,9 +276,9 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'supported_color_modes': list([ , diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 6da99241b64..00763f60458 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -5,12 +5,11 @@ from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components import zeroconf from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,7 +20,6 @@ async def test_full_user_flow_implementation( hass: HomeAssistant, mock_elgato: MagicMock, mock_setup_entry: AsyncMock, - snapshot: SnapshotAssertion, ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -29,15 +27,22 @@ async def test_full_user_flow_implementation( context={"source": SOURCE_USER}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 9123} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_MAC: None, + } + assert not config_entry.options assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 @@ -47,7 +52,6 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_elgato: MagicMock, mock_setup_entry: AsyncMock, - snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -64,9 +68,9 @@ async def test_full_zeroconf_flow_implementation( ), ) - assert result.get("description_placeholders") == {"serial_number": "CN11A1A00001"} - assert result.get("step_id") == "zeroconf_confirm" - assert result.get("type") is FlowResultType.FORM + assert result["description_placeholders"] == {"serial_number": "CN11A1A00001"} + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 @@ -74,12 +78,19 @@ async def test_full_zeroconf_flow_implementation( assert "context" in progress[0] assert progress[0]["context"].get("confirm_only") is True - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + assert not config_entry.options assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 @@ -94,12 +105,31 @@ async def test_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, + data={CONF_HOST: "127.0.0.1"}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert result["step_id"] == "user" + + # Recover from error + mock_elgato.info.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "127.0.0.2"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.2", + CONF_MAC: None, + } + assert not config_entry.options async def test_zeroconf_connection_error( @@ -122,8 +152,8 @@ async def test_zeroconf_connection_error( ), ) - assert result.get("reason") == "cannot_connect" - assert result.get("type") is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert result["type"] is FlowResultType.ABORT @pytest.mark.usefixtures("mock_elgato") @@ -135,11 +165,11 @@ async def test_user_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, + data={CONF_HOST: "127.0.0.1"}, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("mock_elgato") @@ -162,8 +192,8 @@ async def test_zeroconf_device_exists_abort( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.1" @@ -183,8 +213,8 @@ async def test_zeroconf_device_exists_abort( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.2" @@ -195,7 +225,6 @@ async def test_zeroconf_during_onboarding( mock_elgato: MagicMock, mock_setup_entry: AsyncMock, mock_onboarding: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf creates an entry during onboarding.""" result = await hass.config_entries.flow.async_init( @@ -212,8 +241,15 @@ async def test_zeroconf_during_onboarding( ), ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + assert not config_entry.options assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 40c0232c2b3..43fad1faa77 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -9,7 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.elgato.const import DOMAIN, SERVICE_IDENTIFY from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, DOMAIN as LIGHT_DOMAIN, ) @@ -74,7 +74,7 @@ async def test_light_change_state_temperature( { ATTR_ENTITY_ID: "light.frenck", ATTR_BRIGHTNESS: 255, - ATTR_COLOR_TEMP: 100, + ATTR_COLOR_TEMP_KELVIN: 10000, }, blocking=True, ) diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index f92fc2f1827..f8cf33ffe1a 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -1,6 +1,7 @@ """Configuration for Elmax tests.""" from collections.abc import Generator +from datetime import datetime, timedelta import json from unittest.mock import AsyncMock, patch @@ -11,6 +12,7 @@ from elmax_api.constants import ( ENDPOINT_LOGIN, ) from httpx import Response +import jwt import pytest import respx @@ -64,9 +66,20 @@ def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]: ) as respx_mock: # Mock Login POST. login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login") - login_route.return_value = Response( - 200, json=json.loads(load_fixture("direct/login.json", "elmax")) + + login_json = json.loads(load_fixture("direct/login.json", "elmax")) + decoded_jwt = jwt.decode_complete( + login_json["token"].split(" ")[1], + algorithms="HS256", + options={"verify_signature": False}, ) + expiration = datetime.now() + timedelta(hours=1) + decoded_jwt["payload"]["exp"] = int(expiration.timestamp()) + jws_string = jwt.encode( + payload=decoded_jwt["payload"], algorithm="HS256", key="" + ) + login_json["token"] = f"JWT {jws_string}" + login_route.return_value = Response(200, json=login_json) # Mock Device list GET. list_devices_route = respx_mock.get( diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index a445f8bae0d..8a340d5e2dd 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -793,7 +793,10 @@ async def test_put_light_state( await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_ON, - {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_COLOR_TEMP: 20}, + { + const.ATTR_ENTITY_ID: "light.ceiling_lights", + light.ATTR_COLOR_TEMP_KELVIN: 50000, + }, blocking=True, ) @@ -802,8 +805,10 @@ async def test_put_light_state( ) assert ( - hass_hue.states.get("light.ceiling_lights").attributes[light.ATTR_COLOR_TEMP] - == 50 + hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_COLOR_TEMP_KELVIN + ] + == 20000 ) # mock light.turn_on call @@ -1785,7 +1790,7 @@ async def test_get_light_state_when_none( light.ATTR_BRIGHTNESS: None, light.ATTR_RGB_COLOR: None, light.ATTR_HS_COLOR: None, - light.ATTR_COLOR_TEMP: None, + light.ATTR_COLOR_TEMP_KELVIN: None, light.ATTR_XY_COLOR: None, light.ATTR_SUPPORTED_COLOR_MODES: [ light.COLOR_MODE_COLOR_TEMP, @@ -1813,7 +1818,7 @@ async def test_get_light_state_when_none( light.ATTR_BRIGHTNESS: None, light.ATTR_RGB_COLOR: None, light.ATTR_HS_COLOR: None, - light.ATTR_COLOR_TEMP: None, + light.ATTR_COLOR_TEMP_KELVIN: None, light.ATTR_XY_COLOR: None, light.ATTR_SUPPORTED_COLOR_MODES: [ light.COLOR_MODE_COLOR_TEMP, diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index 72e504c97c8..88b0af6dc7b 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -28,10 +28,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'EnergyZero', 'unique_id': 'energyzero', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'EnergyZero', 'type': , 'version': 1, diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index 287157026f4..f8e7e75e902 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from energyzero import EnergyZeroConnectionError import pytest -from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -26,7 +25,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py index ab19c2ce51a..d12f96d4b0f 100644 --- a/tests/components/enigma2/test_init.py +++ b/tests/components/enigma2/test_init.py @@ -5,23 +5,37 @@ from unittest.mock import patch from homeassistant.components.enigma2.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import TEST_REQUIRED, MockDevice from tests.common import MockConfigEntry +async def test_device_without_mac_address( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that a device gets successfully registered when the device doesn't report a MAC address.""" + mock_device = MockDevice() + mock_device.mac_address = None + with patch( + "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", + return_value=mock_device, + ): + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None + + async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" - with ( - patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ), - patch( - "homeassistant.components.enigma2.media_player.async_setup_entry", - return_value=True, - ), + with patch( + "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", + return_value=MockDevice(), ): entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") entry.add_to_hass(hass) diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 76835098f27..3cacd3a8518 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -454,6 +456,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -928,6 +932,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 4f7ea679b20..8f1711e829e 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'ESPHome Device', 'unique_id': '11:22:33:44:55:aa', 'version': 1, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 832e7d6572f..0beeae71df3 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -79,6 +79,7 @@ async def test_diagnostics_with_bluetooth( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "11:22:33:44:55:aa", "version": 1, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 7f275fff4f2..8e4f37079d1 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -20,9 +20,7 @@ from homeassistant.components.light import ( ATTR_FLASH, ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -1379,9 +1377,6 @@ async def test_light_color_temp( assert state.state == STATE_ON attributes = state.attributes - assert attributes[ATTR_MIN_MIREDS] == 153 - assert attributes[ATTR_MAX_MIREDS] == 370 - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 await hass.services.async_call( @@ -1454,9 +1449,6 @@ async def test_light_color_temp_no_mireds_set( assert state.state == STATE_ON attributes = state.attributes - assert attributes[ATTR_MIN_MIREDS] is None - assert attributes[ATTR_MAX_MIREDS] is None - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 0 assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 0 await hass.services.async_call( @@ -1558,8 +1550,6 @@ async def test_light_color_temp_legacy( assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] - assert attributes[ATTR_MIN_MIREDS] == 153 - assert attributes[ATTR_MAX_MIREDS] == 370 assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index fbb09ab879c..90061ec60a1 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,7 +1,5 @@ """Tests for fan platforms.""" -from unittest.mock import patch - import pytest from homeassistant.components.fan import ( @@ -13,23 +11,13 @@ from homeassistant.components.fan import ( FanEntityFeature, NotValidPresetModeError, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component from .common import MockFan -from tests.common import ( - MockConfigEntry, - MockModule, - MockPlatform, - mock_integration, - mock_platform, - setup_test_component_platform, -) +from tests.common import setup_test_component_platform class BaseFan(FanEntity): @@ -161,300 +149,3 @@ async def test_preset_mode_validation( with pytest.raises(NotValidPresetModeError) as exc: await test_fan._valid_preset_mode_or_raise("invalid") assert exc.value.translation_key == "not_valid_preset_mode" - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockFan(FanEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockFan() - assert entity.supported_features is FanEntityFeature(1) - assert "MockFan" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "FanEntityFeature.SET_SPEED" in caplog.text - caplog.clear() - assert entity.supported_features is FanEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - -async def test_warning_not_implemented_turn_on_off_feature( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test adding feature flag and warn if missing when methods are set.""" - - called = [] - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - def turn_on( - self, - percentage: int | None = None, - preset_mode: str | None = None, - ) -> None: - """Turn on.""" - called.append("turn_on") - - def turn_off(self) -> None: - """Turn off.""" - called.append("turn_off") - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert ( - "Entity fan.test (.MockFanEntityTest'>) " - "does not set FanEntityFeature.TURN_OFF but implements the turn_off method. Please report it to the author of the 'test' custom integration" - in caplog.text - ) - assert ( - "Entity fan.test (.MockFanEntityTest'>) " - "does not set FanEntityFeature.TURN_ON but implements the turn_on method. Please report it to the author of the 'test' custom integration" - in caplog.text - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON, - { - "entity_id": "fan.test", - }, - blocking=True, - ) - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_OFF, - { - "entity_id": "fan.test", - }, - blocking=True, - ) - - assert len(called) == 2 - assert "turn_on" in called - assert "turn_off" in called - - -async def test_no_warning_implemented_turn_on_off_feature( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test no warning when feature flags are set.""" - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - _attr_supported_features = ( - FanEntityFeature.DIRECTION - | FanEntityFeature.OSCILLATE - | FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert "does not set FanEntityFeature.TURN_OFF" not in caplog.text - assert "does not set FanEntityFeature.TURN_ON" not in caplog.text - - -async def test_no_warning_integration_has_migrated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`.""" - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - _enable_turn_on_off_backwards_compatibility = False - _attr_supported_features = ( - FanEntityFeature.DIRECTION - | FanEntityFeature.OSCILLATE - | FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert "does not set FanEntityFeature.TURN_OFF" not in caplog.text - assert "does not set FanEntityFeature.TURN_ON" not in caplog.text - - -async def test_no_warning_integration_implement_feature_flags( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test no warning when integration uses the correct feature flags.""" - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - _attr_supported_features = ( - FanEntityFeature.DIRECTION - | FanEntityFeature.OSCILLATE - | FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert "does not set FanEntityFeature.TURN_OFF" not in caplog.text - assert "does not set FanEntityFeature.TURN_ON" not in caplog.text diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py index e801227293c..c9fc89179db 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -5,7 +5,6 @@ import urllib import pytest -from homeassistant.components.feedreader import CONF_URLS from homeassistant.components.feedreader.const import ( CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, @@ -13,10 +12,8 @@ from homeassistant.components.feedreader.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_URL -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import create_mock_entry from .const import FEED_TITLE, URL, VALID_CONFIG_DEFAULT @@ -95,65 +92,6 @@ async def test_user_errors( assert result["options"][CONF_MAX_ENTRIES] == DEFAULT_MAX_ENTRIES -@pytest.mark.parametrize( - ("data", "expected_data", "expected_options"), - [ - ({CONF_URLS: [URL]}, {CONF_URL: URL}, {CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES}), - ( - {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}, - {CONF_URL: URL}, - {CONF_MAX_ENTRIES: 5}, - ), - ], -) -async def test_import( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - data, - expected_data, - expected_options, - feedparser, - setup_entry, -) -> None: - """Test starting an import flow.""" - config_entries = hass.config_entries.async_entries(DOMAIN) - assert not config_entries - - assert await async_setup_component(hass, DOMAIN, {DOMAIN: data}) - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert config_entries - assert len(config_entries) == 1 - assert config_entries[0].title == FEED_TITLE - assert config_entries[0].data == expected_data - assert config_entries[0].options == expected_options - - assert issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_feedreader" - ) - - -async def test_import_errors( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - feedparser, - setup_entry, - feed_one_event, -) -> None: - """Test starting an import flow which results in an URL error.""" - config_entries = hass.config_entries.async_entries(DOMAIN) - assert not config_entries - - # raise URLError - feedparser.side_effect = urllib.error.URLError("Test") - feedparser.return_value = None - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_URLS: [URL]}}) - assert issue_registry.async_get_issue( - DOMAIN, - "import_yaml_error_feedreader_url_error_http_some_rss_local_rss_feed_xml", - ) - - async def test_reconfigure(hass: HomeAssistant, feedparser) -> None: """Test starting a reconfigure flow.""" entry = create_mock_entry(VALID_CONFIG_DEFAULT) diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 1976a8f310b..583c44a41e6 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -129,6 +129,62 @@ def mock_light() -> Mock: return light +@pytest.fixture +def mock_thermostat() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 4 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.thermostatDanfoss" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setThermostatMode": 1} + climate.supported_features = {} + climate.has_supported_thermostat_modes = True + climate.supported_thermostat_modes = ["Off", "Heat", "CustomerSpecific"] + climate.has_operating_mode = False + climate.has_thermostat_mode = True + climate.thermostat_mode = "CustomerSpecific" + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + climate.value = value_mock + return climate + + +@pytest.fixture +def mock_thermostat_with_operating_mode() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 4 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.thermostatDanfoss" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setOperationMode": 1} + climate.supported_features = {} + climate.has_supported_operating_modes = True + climate.supported_operating_modes = [0, 1, 15] + climate.has_operating_mode = True + climate.operating_mode = 15 + climate.has_thermostat_mode = False + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + climate.value = value_mock + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py new file mode 100644 index 00000000000..31022e19a08 --- /dev/null +++ b/tests/components/fibaro/test_climate.py @@ -0,0 +1,134 @@ +"""Test the Fibaro climate platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.climate import ClimateEntityFeature, HVACMode +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_climate_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that the climate creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("climate.room_1_test_climate_4") + assert entry + assert entry.unique_id == "hc2_111111.4" + assert entry.original_name == "Room 1 Test climate" + assert entry.supported_features == ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + + +async def test_hvac_mode_preset( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that the climate state is auto when a preset is selected.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_4") + assert state.state == HVACMode.AUTO + assert state.attributes["preset_mode"] == "CustomerSpecific" + + +async def test_hvac_mode_heat( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that the preset mode is None if a hvac mode is active.""" + + # Arrange + mock_thermostat.thermostat_mode = "Heat" + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_4") + assert state.state == HVACMode.HEAT + assert state.attributes["preset_mode"] is None + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that set_hvac_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_4", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat.execute_action.assert_called_once() + + +async def test_hvac_mode_with_operation_mode_support( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_with_operating_mode: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat_with_operating_mode] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_4") + assert state.state == HVACMode.AUTO diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index cc7c9fa0570..a6205bac506 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -44,6 +44,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]: ): flexit_bacnet.serial_number = "0000-0001" flexit_bacnet.device_name = "Device Name" + flexit_bacnet.model = "S4 RER" flexit_bacnet.room_temperature = 19.0 flexit_bacnet.air_temp_setpoint_away = 18.0 flexit_bacnet.air_temp_setpoint_home = 22.0 diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index ab0e8a556c4..f7dc30db240 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -1164,7 +1164,7 @@ async def test_flux_with_multiple_lights( assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] -async def test_flux_with_mired( +async def test_flux_with_temp( hass: HomeAssistant, mock_light_entities: list[MockLight], ) -> None: @@ -1224,7 +1224,7 @@ async def test_flux_with_mired( async_fire_time_changed(hass, test_time) await hass.async_block_till_done() call = turn_on_calls[-1] - assert call.data[light.ATTR_COLOR_TEMP] == 269 + assert call.data[light.ATTR_COLOR_TEMP_KELVIN] == 3708 async def test_flux_with_rgb( diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index c12776eb552..a881bc2ea27 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -41,7 +41,7 @@ from homeassistant.components.flux_led.light import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, @@ -777,12 +777,12 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "rgb"] - assert attributes[ATTR_COLOR_TEMP] == 200 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 5000 await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 370}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 2702}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(2702, 128) @@ -1003,7 +1003,7 @@ async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1012,7 +1012,7 @@ async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154, ATTR_BRIGHTNESS: 255}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493, ATTR_BRIGHTNESS: 255}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1021,7 +1021,7 @@ async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 290}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 3448}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(3448, 255) @@ -1241,7 +1241,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1250,7 +1250,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154, ATTR_BRIGHTNESS: 255}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493, ATTR_BRIGHTNESS: 255}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1259,7 +1259,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 290}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 3448}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(3448, 255) @@ -1316,7 +1316,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 170}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 5882}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(5882, MIN_CCT_BRIGHTNESS) diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index 6ae4c2f6198..c0db54c2d4e 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -23,6 +23,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Green House', 'unique_id': 'unique', 'version': 2, diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index abaad402e1b..8fffb5096bc 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +import pytest + from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, CONF_DAMPING_EVENING, @@ -25,10 +27,10 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_NAME: "Name", @@ -40,13 +42,16 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Name" - assert result2.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Name" + assert config_entry.unique_id is None + assert config_entry.data == { CONF_LATITUDE: 52.42, CONF_LONGITUDE: 4.42, } - assert result2.get("options") == { + assert config_entry.options == { CONF_AZIMUTH: 142, CONF_DECLINATION: 42, CONF_MODULES_POWER: 4242, @@ -55,9 +60,9 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_options_flow_invalid_api( hass: HomeAssistant, - mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test options config flow when API key is invalid.""" @@ -67,10 +72,10 @@ async def test_options_flow_invalid_api( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_KEY: "solarPOWER!", @@ -84,27 +89,11 @@ async def test_options_flow_invalid_api( ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.FORM - assert result2["errors"] == {CONF_API_KEY: "invalid_api_key"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} - -async def test_options_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test config flow options.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "init" - - # With the API key - result2 = await hass.config_entries.options.async_configure( + # Ensure we can recover from this error + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_KEY: "SolarForecast150", @@ -118,8 +107,8 @@ async def test_options_flow( ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_API_KEY: "SolarForecast150", CONF_DECLINATION: 21, CONF_AZIMUTH: 22, @@ -130,9 +119,9 @@ async def test_options_flow( } -async def test_options_flow_without_key( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_options_flow( hass: HomeAssistant, - mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test config flow options.""" @@ -142,11 +131,53 @@ async def test_options_flow_without_key( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # With the API key + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "SolarForecast150", + CONF_DECLINATION: 21, + CONF_AZIMUTH: 22, + CONF_MODULES_POWER: 2122, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: "SolarForecast150", + CONF_DECLINATION: 21, + CONF_AZIMUTH: 22, + CONF_MODULES_POWER: 2122, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_options_flow_without_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow options.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" # Without the API key - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_DECLINATION: 21, @@ -159,8 +190,8 @@ async def test_options_flow_without_key( ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_API_KEY: None, CONF_DECLINATION: 21, CONF_AZIMUTH: 22, diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 53f7093a21b..9b5b8c9353a 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -61,6 +61,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 57b22490ed0..8445e6b6a79 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -3,20 +3,16 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta import json from typing import Any -from freezegun.api import FrozenDateTimeFactory - from homeassistant.components.fronius.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker MOCK_HOST = "http://fronius" @@ -115,24 +111,3 @@ def mock_responses( f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", text=_load(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), ) - - -async def enable_all_entities( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - config_entry_id: str, - time_till_next_update: timedelta, -) -> None: - """Enable all entities for a config entry and fast forward time to receive data.""" - registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(registry, config_entry_id) - for entry in [ - entry - for entry in entities - if entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - ]: - registry.async_update_entity(entry.entity_id, disabled_by=None) - await hass.async_block_till_done() - freezer.tick(time_till_next_update) - async_fire_time_changed(hass) - await hass.async_block_till_done() diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index 010de06e276..b112839835a 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8f8c9d919fc --- /dev/null +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -0,0 +1,9008 @@ +# serializer version: 1 +# name: test_gen24[sensor.inverter_name_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '12345678-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter name AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1589', + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '12345678-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter name AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.3204', + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '12345678-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter name AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.9168', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '12345678-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter name DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0783', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc_2', + 'unique_id': '12345678-current_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter name DC current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0754', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '12345678-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter name DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '411.3811', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc_2', + 'unique_id': '12345678-voltage_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter name DC voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '403.4312', + }) +# --- +# name: test_gen24[sensor.inverter_name_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '12345678-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inverter name Error code', + }), + 'context': , + 'entity_id': 'sensor.inverter_name_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.inverter_name_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '12345678-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter name Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9917', + }) +# --- +# name: test_gen24[sensor.inverter_name_inverter_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_inverter_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inverter state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_state', + 'unique_id': '12345678-inverter_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_inverter_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inverter name Inverter state', + }), + 'context': , + 'entity_id': 'sensor.inverter_name_inverter_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Running', + }) +# --- +# name: test_gen24[sensor.inverter_name_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '12345678-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inverter name Status code', + }), + 'context': , + 'entity_id': 'sensor.inverter_name_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_gen24[sensor.inverter_name_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '12345678-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Inverter name Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.inverter_name_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_gen24[sensor.inverter_name_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '12345678-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter name Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1530193.42', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent', + 'unique_id': '1234567890-power_apparent', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '868.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_1', + 'unique_id': '1234567890-power_apparent_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '243.3', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_2', + 'unique_id': '1234567890-power_apparent_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '323.4', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_3', + 'unique_id': '1234567890-power_apparent_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '301.2', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_1', + 'unique_id': '1234567890-current_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.145', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_2', + 'unique_id': '1234567890-current_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.33', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_3', + 'unique_id': '1234567890-current_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.825', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_frequency_phase_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency phase average', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_phase_average', + 'unique_id': '1234567890-frequency_phase_average', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_frequency_phase_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Smart Meter TS 65A-3 Frequency phase average', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter location', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location', + 'unique_id': '1234567890-meter_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Meter location', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location_description-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter location description', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location_description', + 'unique_id': '1234567890-meter_location_description', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location_description-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Smart Meter TS 65A-3 Meter location description', + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'feed_in', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor', + 'unique_id': '1234567890-power_factor', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.828', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_1', + 'unique_id': '1234567890-power_factor_phase_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.441', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_2', + 'unique_id': '1234567890-power_factor_phase_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.934', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_3', + 'unique_id': '1234567890-power_factor_phase_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 3', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.832', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_consumed', + 'unique_id': '1234567890-energy_reactive_ac_consumed', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy consumed', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88221.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_produced', + 'unique_id': '1234567890-energy_reactive_ac_produced', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy produced', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1989125.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive', + 'unique_id': '1234567890-power_reactive', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-517.4', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_1', + 'unique_id': '1234567890-power_reactive_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-218.6', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_2', + 'unique_id': '1234567890-power_reactive_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-132.8', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_3', + 'unique_id': '1234567890-power_reactive_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-166.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_consumed', + 'unique_id': '1234567890-energy_real_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2013105.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_minus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy minus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_minus', + 'unique_id': '1234567890-energy_real_ac_minus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_minus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy minus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3863340.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_plus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy plus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_plus', + 'unique_id': '1234567890-energy_real_ac_plus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_plus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy plus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2013105.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_produced', + 'unique_id': '1234567890-energy_real_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3863340.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real', + 'unique_id': '1234567890-power_real', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '653.1', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_1', + 'unique_id': '1234567890-power_real_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '106.8', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_2', + 'unique_id': '1234567890-power_real_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '294.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_3', + 'unique_id': '1234567890-power_real_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '251.3', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_1', + 'unique_id': '1234567890-voltage_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1-2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_12', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_12', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1-2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '408.7', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_2', + 'unique_id': '1234567890-voltage_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.1', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2-3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_23', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_23', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2-3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '409.6', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_3', + 'unique_id': '1234567890-voltage_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3-1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_31', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_31', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3-1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '409.4', + }) +# --- +# name: test_gen24[sensor.solarnet_meter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_mode', + 'unique_id': 'solar_net_123.4567890-power_flow-meter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.solarnet_meter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Meter mode', + }), + 'context': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'meter', + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '658.4', + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid export', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_export', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_export', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid import', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_import', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_import', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '658.4', + }) +# --- +# name: test_gen24[sensor.solarnet_power_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-695.6827', + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_consumed', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '695.6827', + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load generated', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_generated', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_generated', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.solarnet_power_photovoltaics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power photovoltaics', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_photovoltaics', + 'unique_id': 'solar_net_123.4567890-power_flow-power_photovoltaics', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_photovoltaics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power photovoltaics', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.9481', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative autonomy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_autonomy', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_autonomy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative autonomy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.3592', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative self consumption', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_self_consumption', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_gen24[sensor.solarnet_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1530193.42', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': 'P030T020Z2001234567 -current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'BYD Battery-Box Premium HV DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': 'P030T020Z2001234567 -voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'BYD Battery-Box Premium HV DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_designed_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_designed_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Designed capacity', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'capacity_designed', + 'unique_id': 'P030T020Z2001234567 -capacity_designed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_designed_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BYD Battery-Box Premium HV Designed capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_designed_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16588', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_maximum_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_maximum_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum capacity', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'capacity_maximum', + 'unique_id': 'P030T020Z2001234567 -capacity_maximum', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_maximum_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BYD Battery-Box Premium HV Maximum capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_maximum_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16588', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge', + 'unique_id': 'P030T020Z2001234567 -state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'BYD Battery-Box Premium HV State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.6', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_cell', + 'unique_id': 'P030T020Z2001234567 -temperature_cell', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BYD Battery-Box Premium HV Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '12345678-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gen24 Storage AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1087', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '12345678-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Gen24 Storage AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.9093', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '12345678-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Gen24 Storage AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '227.354', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '12345678-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gen24 Storage DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3952', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc_2', + 'unique_id': '12345678-current_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gen24 Storage DC current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3564', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '12345678-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Gen24 Storage DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '419.1009', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc_2', + 'unique_id': '12345678-voltage_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Gen24 Storage DC voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318.8103', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '12345678-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gen24 Storage Error code', + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '12345678-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gen24 Storage Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9816', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_inverter_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_inverter_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inverter state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_state', + 'unique_id': '12345678-inverter_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_inverter_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gen24 Storage Inverter state', + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_inverter_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Running', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '12345678-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gen24 Storage Status code', + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '12345678-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gen24 Storage Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '12345678-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Gen24 Storage Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7512794.0117', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohmpilot_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_consumed', + 'unique_id': '23456789-energy_real_ac_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ohmpilot Energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1233295.0', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohmpilot_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_ac', + 'unique_id': '23456789-power_real_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ohmpilot Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ohmpilot_state_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'State code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_code', + 'unique_id': '23456789-state_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohmpilot State code', + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_state_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'up_and_running', + 'keep_minimum_temperature', + 'legionella_protection', + 'critical_fault', + 'fault', + 'boost_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ohmpilot_state_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_message', + 'unique_id': '23456789-state_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Ohmpilot State message', + 'options': list([ + 'up_and_running', + 'keep_minimum_temperature', + 'legionella_protection', + 'critical_fault', + 'fault', + 'boost_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_state_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up_and_running', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohmpilot_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_channel_1', + 'unique_id': '23456789-temperature_channel_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Ohmpilot Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent', + 'unique_id': '1234567890-power_apparent', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '821.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_1', + 'unique_id': '1234567890-power_apparent_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '319.5', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_2', + 'unique_id': '1234567890-power_apparent_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '383.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_3', + 'unique_id': '1234567890-power_apparent_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '118.4', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_1', + 'unique_id': '1234567890-current_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.701', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_2', + 'unique_id': '1234567890-current_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.832', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_3', + 'unique_id': '1234567890-current_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.645', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_frequency_phase_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency phase average', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_phase_average', + 'unique_id': '1234567890-frequency_phase_average', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_frequency_phase_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Smart Meter TS 65A-3 Frequency phase average', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter location', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location', + 'unique_id': '1234567890-meter_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Meter location', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location_description-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter location description', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location_description', + 'unique_id': '1234567890-meter_location_description', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location_description-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Smart Meter TS 65A-3 Meter location description', + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'feed_in', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor', + 'unique_id': '1234567890-power_factor', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.698', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_1', + 'unique_id': '1234567890-power_factor_phase_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.995', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_2', + 'unique_id': '1234567890-power_factor_phase_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.389', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_3', + 'unique_id': '1234567890-power_factor_phase_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 3', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.163', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_consumed', + 'unique_id': '1234567890-energy_reactive_ac_consumed', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy consumed', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5482.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_produced', + 'unique_id': '1234567890-energy_reactive_ac_produced', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy produced', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3266105.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive', + 'unique_id': '1234567890-power_reactive', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-501.5', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_1', + 'unique_id': '1234567890-power_reactive_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-31.3', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_2', + 'unique_id': '1234567890-power_reactive_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-353.4', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_3', + 'unique_id': '1234567890-power_reactive_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-116.7', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_consumed', + 'unique_id': '1234567890-energy_real_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1247204.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_minus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy minus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_minus', + 'unique_id': '1234567890-energy_real_ac_minus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_minus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy minus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1705128.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_plus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy plus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_plus', + 'unique_id': '1234567890-energy_real_ac_plus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_plus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy plus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1247204.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_produced', + 'unique_id': '1234567890-energy_real_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1705128.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real', + 'unique_id': '1234567890-power_real', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '487.7', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_1', + 'unique_id': '1234567890-power_real_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '317.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_2', + 'unique_id': '1234567890-power_real_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_3', + 'unique_id': '1234567890-power_real_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_1', + 'unique_id': '1234567890-voltage_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.4', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1-2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_12', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_12', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1-2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '396.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_2', + 'unique_id': '1234567890-voltage_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '225.6', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2-3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_23', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_23', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2-3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '393.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_3', + 'unique_id': '1234567890-voltage_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '228.3', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3-1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_31', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_31', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3-1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '394.3', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_meter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_mode', + 'unique_id': 'solar_net_12345678-power_flow-meter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.solarnet_meter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Meter mode', + }), + 'context': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bidirectional', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power battery', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_battery', + 'unique_id': 'solar_net_12345678-power_flow-power_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1591', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_battery_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power battery charge', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_battery_charge', + 'unique_id': 'solar_net_12345678-power_flow-power_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power battery charge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_battery_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_discharge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_battery_discharge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power battery discharge', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_battery_discharge', + 'unique_id': 'solar_net_12345678-power_flow-power_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_discharge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power battery discharge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_battery_discharge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1591', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid', + 'unique_id': 'solar_net_12345678-power_flow-power_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2274.9', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid export', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_export', + 'unique_id': 'solar_net_12345678-power_flow-power_grid_export', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid import', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_import', + 'unique_id': 'solar_net_12345678-power_flow-power_grid_import', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2274.9', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load', + 'unique_id': 'solar_net_12345678-power_flow-power_load', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2459.3092', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_consumed', + 'unique_id': 'solar_net_12345678-power_flow-power_load_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2459.3092', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load generated', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_generated', + 'unique_id': 'solar_net_12345678-power_flow-power_load_generated', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_photovoltaics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power photovoltaics', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_photovoltaics', + 'unique_id': 'solar_net_12345678-power_flow-power_photovoltaics', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_photovoltaics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power photovoltaics', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.4328', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative autonomy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_autonomy', + 'unique_id': 'solar_net_12345678-power_flow-relative_autonomy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative autonomy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4984', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative self consumption', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_self_consumption', + 'unique_id': 'solar_net_12345678-power_flow-relative_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'solar_net_12345678-power_flow-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7512664.4042', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '234567-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 3.0-1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.32', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '234567-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Primo 3.0-1 AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '296', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '234567-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 3.0-1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '223.6', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '234567-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 3.0-1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.97', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '234567-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 3.0-1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '329.5', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_energy_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy day', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_day', + 'unique_id': '234567-energy_day', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 3.0-1 Energy day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_energy_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14237', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_energy_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy year', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_year', + 'unique_id': '234567-energy_year', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 3.0-1 Energy year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_energy_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3596193.25', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '234567-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 Error code', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '234567-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Primo 3.0-1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.01', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_led_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED color', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_color', + 'unique_id': '234567-led_color', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 LED color', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_led_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_led_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_state', + 'unique_id': '234567-led_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 LED state', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_led_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '234567-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 Status code', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '234567-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Primo 3.0-1 Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '234567-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 3.0-1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5796010', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '123456-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 5.0-1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.85', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '123456-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Primo 5.0-1 AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '862', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '123456-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 5.0-1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '223.9', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '123456-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 5.0-1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.23', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '123456-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 5.0-1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '452.3', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_energy_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy day', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_day', + 'unique_id': '123456-energy_day', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 5.0-1 Energy day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_energy_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22504', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_energy_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy year', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_year', + 'unique_id': '123456-energy_year', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 5.0-1 Energy year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_energy_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7532755.5', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '123456-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 Error code', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '123456-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Primo 5.0-1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_led_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED color', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_color', + 'unique_id': '123456-led_color', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 LED color', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_led_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_led_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_state', + 'unique_id': '123456-led_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 LED state', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_led_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '123456-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 Status code', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '123456-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Primo 5.0-1 Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '123456-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 5.0-1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17114940', + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter location', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location', + 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-meter_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S0 Meter at inverter 1 Meter location', + }), + 'context': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location_description-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location_description', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter location description', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location_description', + 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-meter_location_description', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location_description-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'S0 Meter at inverter 1 Meter location description', + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'context': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location_description', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'consumption_path', + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_real_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_at_inverter_1_real_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real', + 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-power_real', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_real_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'S0 Meter at inverter 1 Real power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_real_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2216.7487', + }) +# --- +# name: test_primo_s0[sensor.solarnet_co2_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_co2_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CO₂ factor', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'co2_factor', + 'unique_id': '123.4567890-co2_factor', + 'unit_of_measurement': 'kg/kWh', + }) +# --- +# name: test_primo_s0[sensor.solarnet_co2_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet CO₂ factor', + 'state_class': , + 'unit_of_measurement': 'kg/kWh', + }), + 'context': , + 'entity_id': 'sensor.solarnet_co2_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.53', + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_energy_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy day', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_day', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_day', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Energy day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_energy_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36724', + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_energy_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy year', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_year', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_year', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Energy year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_energy_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11128933.25', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_export_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_grid_export_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid export tariff', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cash_factor', + 'unique_id': '123.4567890-cash_factor', + 'unit_of_measurement': 'BRL/kWh', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_export_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Grid export tariff', + 'state_class': , + 'unit_of_measurement': 'BRL/kWh', + }), + 'context': , + 'entity_id': 'sensor.solarnet_grid_export_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_import_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_grid_import_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid import tariff', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'delivery_factor', + 'unique_id': '123.4567890-delivery_factor', + 'unit_of_measurement': 'BRL/kWh', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_import_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Grid import tariff', + 'state_class': , + 'unit_of_measurement': 'BRL/kWh', + }), + 'context': , + 'entity_id': 'sensor.solarnet_grid_import_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_primo_s0[sensor.solarnet_meter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_mode', + 'unique_id': 'solar_net_123.4567890-power_flow-meter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.solarnet_meter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Meter mode', + }), + 'context': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'vague-meter', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '384.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid export', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_export', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_export', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid import', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_import', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_import', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '384.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2218.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_consumed', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2218.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load generated', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_generated', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_generated', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_photovoltaics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power photovoltaics', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_photovoltaics', + 'unique_id': 'solar_net_123.4567890-power_flow-power_photovoltaics', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_photovoltaics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power photovoltaics', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1834', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative autonomy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_autonomy', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_autonomy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative autonomy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82.6523', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative self consumption', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_self_consumption', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_primo_s0[sensor.solarnet_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22910919.5', + }) +# --- diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 1b9c41d5aa6..933b8fad8ef 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -44,43 +44,62 @@ MOCK_DHCP_DATA = DhcpServiceInfo( ) -async def test_form_with_logger(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] - - with ( - patch( - "pyfronius.Fronius.current_logger_info", - return_value=LOGGER_INFO_RETURN_VALUE, - ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, +async def assert_finish_flow_with_logger(hass: HomeAssistant, flow_id: str) -> None: + """Assert finishing the flow with a logger device.""" + with patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result = await hass.config_entries.flow.async_configure( + flow_id, { "host": "10.9.8.1", }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "SolarNet Datalogger at 10.9.8.1" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "SolarNet Datalogger at 10.9.8.1" + assert result["data"] == { "host": "10.9.8.1", "is_logger": True, } - assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "123.4567" + + +async def assert_abort_flow_with_logger( + hass: HomeAssistant, flow_id: str, reason: str +) -> config_entries.ConfigFlowResult: + """Assert the flow was aborted when a logger device responded.""" + with patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + "host": "10.9.8.1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + return result + + +async def test_form_with_logger(hass: HomeAssistant) -> None: + """Test the basic flow with a logger device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + await assert_finish_flow_with_logger(hass, result["flow_id"]) async def test_form_with_inverter(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test the basic flow with a Gen24 device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -96,10 +115,6 @@ async def test_form_with_inverter(hass: HomeAssistant) -> None: "pyfronius.Fronius.inverter_info", return_value=INVERTER_INFO_RETURN_VALUE, ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -115,11 +130,21 @@ async def test_form_with_inverter(hass: HomeAssistant) -> None: "host": "10.9.1.1", "is_logger": False, } - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == "1234567" -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "inverter_side_effect", + [ + FroniusError, + None, # raises StopIteration through INVERTER_INFO_NONE + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, inverter_side_effect: type[FroniusError] | None +) -> None: """Test we handle cannot connect error.""" + INVERTER_INFO_NONE: dict[str, list] = {"inverters": []} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -131,34 +156,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ), patch( "pyfronius.Fronius.inverter_info", - side_effect=FroniusError, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_no_device(hass: HomeAssistant) -> None: - """Test we handle no device found error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch( - "pyfronius.Fronius.current_logger_info", - side_effect=FroniusError, - ), - patch( - "pyfronius.Fronius.inverter_info", - return_value={"inverters": []}, + side_effect=inverter_side_effect, + return_value=INVERTER_INFO_NONE, ), ): result2 = await hass.config_entries.flow.async_configure( @@ -170,6 +169,7 @@ async def test_form_no_device(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + await assert_finish_flow_with_logger(hass, result2["flow_id"]) async def test_form_unexpected(hass: HomeAssistant) -> None: @@ -191,13 +191,14 @@ async def test_form_unexpected(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + await assert_finish_flow_with_logger(hass, result2["flow_id"]) async def test_form_already_existing(hass: HomeAssistant) -> None: """Test existing entry.""" MockConfigEntry( domain=DOMAIN, - unique_id="123.4567", + unique_id=LOGGER_INFO_RETURN_VALUE["unique_identifier"]["value"], data={CONF_HOST: "10.9.8.1", "is_logger": True}, ).add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -205,26 +206,15 @@ async def test_form_already_existing(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "pyfronius.Fronius.current_logger_info", - return_value=LOGGER_INFO_RETURN_VALUE, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "10.9.8.1", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + await assert_abort_flow_with_logger( + hass, result["flow_id"], reason="already_configured" + ) -async def test_form_updates_host( +async def test_config_flow_already_configured( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test existing entry gets updated.""" + """Test existing entry doesn't get updated by config flow.""" old_host = "http://10.1.0.1" new_host = "http://10.1.0.2" entry = MockConfigEntry( @@ -247,26 +237,20 @@ async def test_form_updates_host( ) mock_responses(aioclient_mock, host=new_host) - with patch( - "homeassistant.components.fronius.async_unload_entry", - return_value=True, - ) as mock_unload_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": new_host, - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": new_host, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" - mock_unload_entry.assert_called_with(hass, entry) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].data == { - "host": new_host, + "host": old_host, # not updated from config flow - only from reconfigure flow "is_logger": True, } @@ -295,6 +279,7 @@ async def test_dhcp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> "host": MOCK_DHCP_DATA.ip, "is_logger": True, } + assert result["result"].unique_id == "123.4567" async def test_dhcp_already_configured( @@ -342,11 +327,13 @@ async def test_dhcp_invalid( async def test_reconfigure(hass: HomeAssistant) -> None: """Test reconfiguring an entry.""" + old_host = "http://10.1.0.1" + new_host = "http://10.1.0.2" entry = MockConfigEntry( domain=DOMAIN, unique_id="1234567", data={ - CONF_HOST: "10.1.2.3", + CONF_HOST: old_host, "is_logger": True, }, ) @@ -365,15 +352,11 @@ async def test_reconfigure(hass: HomeAssistant) -> None: "pyfronius.Fronius.inverter_info", return_value=INVERTER_INFO_RETURN_VALUE, ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "host": "10.9.1.1", + "host": new_host, }, ) await hass.async_block_till_done() @@ -381,17 +364,16 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data == { - "host": "10.9.1.1", + "host": new_host, "is_logger": False, } - assert len(mock_setup_entry.mock_calls) == 1 async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="123.4567890", + unique_id=LOGGER_INFO_RETURN_VALUE["unique_identifier"]["value"], data={ CONF_HOST: "10.1.2.3", "is_logger": True, @@ -421,12 +403,16 @@ async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + await assert_abort_flow_with_logger( + hass, result2["flow_id"], reason="reconfigure_successful" + ) + async def test_reconfigure_unexpected(hass: HomeAssistant) -> None: """Test we handle unexpected error.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="123.4567890", + unique_id=LOGGER_INFO_RETURN_VALUE["unique_identifier"]["value"], data={ CONF_HOST: "10.1.2.3", "is_logger": True, @@ -450,12 +436,16 @@ async def test_reconfigure_unexpected(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + await assert_abort_flow_with_logger( + hass, result2["flow_id"], reason="reconfigure_successful" + ) -async def test_reconfigure_already_configured(hass: HomeAssistant) -> None: - """Test reconfiguring an entry.""" + +async def test_reconfigure_to_different_device(hass: HomeAssistant) -> None: + """Test reconfiguring an entry to a different device.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="123.4567890", + unique_id="999.9999999", data={ CONF_HOST: "10.1.2.3", "is_logger": True, @@ -467,68 +457,6 @@ async def test_reconfigure_already_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "pyfronius.Fronius.current_logger_info", - return_value=LOGGER_INFO_RETURN_VALUE, - ), - patch( - "pyfronius.Fronius.inverter_info", - return_value=INVERTER_INFO_RETURN_VALUE, - ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "host": "10.1.2.3", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unique_id_mismatch" - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_reconfigure_already_existing(hass: HomeAssistant) -> None: - """Test reconfiguring entry to already existing device.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="123.4567890", - data={ - CONF_HOST: "10.1.2.3", - "is_logger": True, - }, + await assert_abort_flow_with_logger( + hass, result["flow_id"], reason="unique_id_mismatch" ) - entry.add_to_hass(hass) - - entry_2_uid = "222.2222222" - entry_2 = MockConfigEntry( - domain=DOMAIN, - unique_id=entry_2_uid, - data={ - CONF_HOST: "10.2.2.2", - "is_logger": True, - }, - ) - entry_2.add_to_hass(hass) - - result = await entry.start_reconfigure_flow(hass) - with patch( - "pyfronius.Fronius.current_logger_info", - return_value={"unique_identifier": {"value": entry_2_uid}}, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "10.1.1.1", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "unique_id_mismatch" diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py index 13a08bbe70e..fab2d509767 100644 --- a/tests/components/fronius/test_coordinator.py +++ b/tests/components/fronius/test_coordinator.py @@ -29,7 +29,7 @@ async def test_adaptive_update_interval( mock_inverter_data.reset_mock() freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -38,13 +38,13 @@ async def test_adaptive_update_interval( # first 3 bad requests at default interval - 4th has different interval for _ in range(3): freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_inverter_data.call_count == 3 mock_inverter_data.reset_mock() freezer.tick(FroniusInverterUpdateCoordinator.error_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_inverter_data.call_count == 1 mock_inverter_data.reset_mock() @@ -52,13 +52,13 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = None # next successful request resets to default interval freezer.tick(FroniusInverterUpdateCoordinator.error_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -68,7 +68,7 @@ async def test_adaptive_update_interval( # first 3 requests at default interval - 4th has different interval for _ in range(3): freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() # BadStatusError does 3 silent retries for inverter endpoint * 3 request intervals = 9 assert mock_inverter_data.call_count == 9 diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index 9d570785073..a950ed4e296 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pyfronius import FroniusError from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER @@ -10,7 +11,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration @@ -66,6 +66,7 @@ async def test_inverter_night_rescan( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test dynamic adding of an inverter discovered automatically after a Home Assistant reboot during the night.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) @@ -78,9 +79,8 @@ async def test_inverter_night_rescan( # Switch to daytime mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() # We expect our inverter to be present now @@ -88,9 +88,8 @@ async def test_inverter_night_rescan( assert inverter_1.manufacturer == "Fronius" # After another re-scan we still only expect this inverter - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) assert inverter_1.manufacturer == "Fronius" @@ -100,6 +99,7 @@ async def test_inverter_rescan_interruption( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test interruption of re-scan during runtime to process further.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) @@ -115,9 +115,8 @@ async def test_inverter_rescan_interruption( "pyfronius.Fronius.inverter_info", side_effect=FroniusError, ): - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() # No increase of devices expected because of a FroniusError @@ -132,9 +131,8 @@ async def test_inverter_rescan_interruption( # Next re-scan will pick up the new inverter. Expect 2 devices now. mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 04c25ce26f2..b5d051d56ca 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -2,27 +2,29 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, - FroniusMeterUpdateCoordinator, FroniusPowerFlowUpdateCoordinator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import enable_all_entities, mock_responses, setup_fronius_integration +from . import mock_responses, setup_fronius_integration -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_symo_inverter( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Symo inverter entities.""" @@ -32,15 +34,8 @@ async def test_symo_inverter( # Init at night mock_responses(aioclient_mock, night=True) - config_entry = await setup_fronius_integration(hass) + await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusInverterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 assert_state("sensor.symo_20_dc_current", 0) assert_state("sensor.symo_20_energy_day", 10828) @@ -54,13 +49,6 @@ async def test_symo_inverter( freezer.tick(FroniusInverterUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 62 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusInverterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # 4 additional AC entities assert_state("sensor.symo_20_dc_current", 2.19) @@ -104,6 +92,7 @@ async def test_symo_logger( assert_state("sensor.solarnet_grid_import_tariff", 0.15) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_symo_meter( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -117,15 +106,8 @@ async def test_symo_meter( assert state.state == str(expected_state) mock_responses(aioclient_mock) - config_entry = await setup_fronius_integration(hass) + await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 26 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # states are rounded to 4 decimals assert_state("sensor.smart_meter_63a_current_phase_1", 7.755) @@ -206,6 +188,7 @@ async def test_symo_meter_forged( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_symo_power_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -220,15 +203,8 @@ async def test_symo_power_flow( # First test at night mock_responses(aioclient_mock, night=True) - config_entry = await setup_fronius_integration(hass) + await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusInverterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # states are rounded to 4 decimals assert_state("sensor.solarnet_energy_day", 10828) @@ -277,10 +253,13 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_self_consumption", 0) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_gen24( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Gen24 inverter entities.""" @@ -292,72 +271,10 @@ async def test_gen24( mock_responses(aioclient_mock, fixture_set="gen24") config_entry = await setup_fronius_integration(hass, is_logger=False) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 - # inverter 1 - assert_state("sensor.inverter_name_ac_current", 0.1589) - assert_state("sensor.inverter_name_dc_current_2", 0.0754) - assert_state("sensor.inverter_name_status_code", 7) - assert_state("sensor.inverter_name_status_message", "running") - assert_state("sensor.inverter_name_dc_current", 0.0783) - assert_state("sensor.inverter_name_dc_voltage_2", 403.4312) - assert_state("sensor.inverter_name_ac_power", 37.3204) - assert_state("sensor.inverter_name_error_code", 0) - assert_state("sensor.inverter_name_dc_voltage", 411.3811) - assert_state("sensor.inverter_name_total_energy", 1530193.42) - assert_state("sensor.inverter_name_inverter_state", "Running") - assert_state("sensor.inverter_name_ac_voltage", 234.9168) - assert_state("sensor.inverter_name_frequency", 49.9917) - # meter - assert_state("sensor.smart_meter_ts_65a_3_real_energy_produced", 3863340.0) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 2013105.0) - assert_state("sensor.smart_meter_ts_65a_3_real_power", 653.1) - assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) - assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0) - assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in") - assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.828) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 88221.0) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 3863340.0) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_2", 2.33) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1", 235.9) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1_2", 408.7) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_2", 294.9) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_plus", 2013105.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2", 236.1) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 1989125.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3", 236.9) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_1", 0.441) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2_3", 409.6) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_3", 1.825) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_3", 0.832) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_1", 243.3) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3_1", 409.4) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_2", 323.4) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_3", 301.2) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_1", 106.8) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_2", 0.934) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 251.3) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_1", -218.6) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_2", -132.8) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_3", -166.0) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power", 868.0) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -517.4) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_1", 1.145) - # power_flow - assert_state("sensor.solarnet_power_grid", 658.4) - assert_state("sensor.solarnet_relative_self_consumption", 100.0) - assert_state("sensor.solarnet_power_photovoltaics", 62.9481) - assert_state("sensor.solarnet_power_load", -695.6827) - assert_state("sensor.solarnet_meter_mode", "meter") - assert_state("sensor.solarnet_relative_autonomy", 5.3592) - assert_state("sensor.solarnet_total_energy", 1530193.42) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + assert_state("sensor.inverter_name_total_energy", 1530193.42) # Gen24 devices may report 0 for total energy while doing firmware updates. # This should yield "unknown" state instead of 0. mock_responses( @@ -375,11 +292,14 @@ async def test_gen24( assert_state("sensor.inverter_name_total_energy", "unknown") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_gen24_storage( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -393,87 +313,8 @@ async def test_gen24_storage( hass, is_logger=False, unique_id="12345678" ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 37 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 72 - # inverter 1 - assert_state("sensor.gen24_storage_dc_current", 0.3952) - assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103) - assert_state("sensor.gen24_storage_dc_current_2", 0.3564) - assert_state("sensor.gen24_storage_ac_current", 1.1087) - assert_state("sensor.gen24_storage_ac_power", 250.9093) - assert_state("sensor.gen24_storage_error_code", 0) - assert_state("sensor.gen24_storage_status_code", 7) - assert_state("sensor.gen24_storage_status_message", "running") - assert_state("sensor.gen24_storage_total_energy", 7512794.0117) - assert_state("sensor.gen24_storage_inverter_state", "Running") - assert_state("sensor.gen24_storage_dc_voltage", 419.1009) - assert_state("sensor.gen24_storage_ac_voltage", 227.354) - assert_state("sensor.gen24_storage_frequency", 49.9816) - # meter - assert_state("sensor.smart_meter_ts_65a_3_real_energy_produced", 1705128.0) - assert_state("sensor.smart_meter_ts_65a_3_real_power", 487.7) - assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.698) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 1247204.0) - assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) - assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0) - assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in") - assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -501.5) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 3266105.0) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 19.6) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_3", 0.645) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 1705128.0) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_2", 383.9) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_1", 1.701) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_2", 1.832) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_1", 319.5) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1", 229.4) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_2", 150.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3_1", 394.3) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2", 225.6) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 5482.0) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_plus", 1247204.0) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_1", 0.995) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_3", 0.163) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_2", 0.389) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_1", -31.3) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_3", -116.7) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1_2", 396.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2_3", 393.0) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_2", -353.4) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_1", 317.9) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3", 228.3) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power", 821.9) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_3", 118.4) - # ohmpilot - assert_state("sensor.ohmpilot_energy_consumed", 1233295.0) - assert_state("sensor.ohmpilot_power", 0.0) - assert_state("sensor.ohmpilot_temperature", 38.9) - assert_state("sensor.ohmpilot_state_code", 0.0) - assert_state("sensor.ohmpilot_state_message", "up_and_running") - # power_flow - assert_state("sensor.solarnet_power_grid", 2274.9) - assert_state("sensor.solarnet_power_battery", 0.1591) - assert_state("sensor.solarnet_power_battery_charge", 0) - assert_state("sensor.solarnet_power_battery_discharge", 0.1591) - assert_state("sensor.solarnet_power_load", -2459.3092) - assert_state("sensor.solarnet_relative_self_consumption", 100.0) - assert_state("sensor.solarnet_power_photovoltaics", 216.4328) - assert_state("sensor.solarnet_relative_autonomy", 7.4984) - assert_state("sensor.solarnet_meter_mode", "bidirectional") - assert_state("sensor.solarnet_total_energy", 7512664.4042) - # storage - assert_state("sensor.byd_battery_box_premium_hv_dc_current", 0.0) - assert_state("sensor.byd_battery_box_premium_hv_state_of_charge", 4.6) - assert_state("sensor.byd_battery_box_premium_hv_maximum_capacity", 16588) - assert_state("sensor.byd_battery_box_premium_hv_temperature", 21.5) - assert_state("sensor.byd_battery_box_premium_hv_designed_capacity", 16588) - assert_state("sensor.byd_battery_box_premium_hv_dc_voltage", 0.0) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) # Devices solar_net = device_registry.async_get_device( @@ -507,11 +348,14 @@ async def test_gen24_storage( assert storage.name == "BYD Battery-Box Premium HV" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_primo_s0( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -523,64 +367,8 @@ async def test_primo_s0( mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) config_entry = await setup_fronius_integration(hass, is_logger=True) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 31 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 47 - # logger - assert_state("sensor.solarnet_grid_export_tariff", 1) - assert_state("sensor.solarnet_co2_factor", 0.53) - assert_state("sensor.solarnet_grid_import_tariff", 1) - # inverter 1 - assert_state("sensor.primo_5_0_1_total_energy", 17114940) - assert_state("sensor.primo_5_0_1_energy_day", 22504) - assert_state("sensor.primo_5_0_1_dc_voltage", 452.3) - assert_state("sensor.primo_5_0_1_ac_power", 862) - assert_state("sensor.primo_5_0_1_error_code", 0) - assert_state("sensor.primo_5_0_1_dc_current", 4.23) - assert_state("sensor.primo_5_0_1_status_code", 7) - assert_state("sensor.primo_5_0_1_status_message", "running") - assert_state("sensor.primo_5_0_1_energy_year", 7532755.5) - assert_state("sensor.primo_5_0_1_ac_current", 3.85) - assert_state("sensor.primo_5_0_1_ac_voltage", 223.9) - assert_state("sensor.primo_5_0_1_frequency", 60) - assert_state("sensor.primo_5_0_1_led_color", 2) - assert_state("sensor.primo_5_0_1_led_state", 0) - # inverter 2 - assert_state("sensor.primo_3_0_1_total_energy", 5796010) - assert_state("sensor.primo_3_0_1_energy_day", 14237) - assert_state("sensor.primo_3_0_1_dc_voltage", 329.5) - assert_state("sensor.primo_3_0_1_ac_power", 296) - assert_state("sensor.primo_3_0_1_error_code", 0) - assert_state("sensor.primo_3_0_1_dc_current", 0.97) - assert_state("sensor.primo_3_0_1_status_code", 7) - assert_state("sensor.primo_3_0_1_status_message", "running") - assert_state("sensor.primo_3_0_1_energy_year", 3596193.25) - assert_state("sensor.primo_3_0_1_ac_current", 1.32) - assert_state("sensor.primo_3_0_1_ac_voltage", 223.6) - assert_state("sensor.primo_3_0_1_frequency", 60.01) - assert_state("sensor.primo_3_0_1_led_color", 2) - assert_state("sensor.primo_3_0_1_led_state", 0) - # meter - assert_state("sensor.s0_meter_at_inverter_1_meter_location", 1) - assert_state( - "sensor.s0_meter_at_inverter_1_meter_location_description", "consumption_path" - ) - assert_state("sensor.s0_meter_at_inverter_1_real_power", -2216.7487) - # power_flow - assert_state("sensor.solarnet_power_load", -2218.9349) - assert_state("sensor.solarnet_meter_mode", "vague-meter") - assert_state("sensor.solarnet_power_photovoltaics", 1834) - assert_state("sensor.solarnet_power_grid", 384.9349) - assert_state("sensor.solarnet_relative_self_consumption", 100) - assert_state("sensor.solarnet_relative_autonomy", 82.6523) - assert_state("sensor.solarnet_total_energy", 22910919.5) - assert_state("sensor.solarnet_energy_day", 36724) - assert_state("sensor.solarnet_energy_year", 11128933.25) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) # Devices solar_net = device_registry.async_get_device( diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index eb19797e5b1..f1792cb7535 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'fyta_user', 'unique_id': None, 'version': 1, diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index e47b78aa893..21101db8534 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -10,6 +10,7 @@ from fyta_cli.fyta_exceptions import ( import pytest from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -20,6 +21,26 @@ from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME from tests.common import MockConfigEntry +async def user_step( + hass: HomeAssistant, flow_id: str, mock_setup_entry: AsyncMock +) -> None: + """Test user step (helper function).""" + + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: EXPIRATION, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_flow( hass: HomeAssistant, mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock ) -> None: @@ -31,20 +52,7 @@ async def test_user_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == USERNAME - assert result2["data"] == { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_EXPIRATION: EXPIRATION, - } - assert len(mock_setup_entry.mock_calls) == 1 + await user_step(hass, result["flow_id"], mock_setup_entry) @pytest.mark.parametrize( @@ -190,3 +198,27 @@ async def test_reauth( assert entry.data[CONF_PASSWORD] == "other_password" assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN assert entry.data[CONF_EXPIRATION] == EXPIRATION + + +async def test_dhcp_discovery( + hass: HomeAssistant, mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test DHCP discovery flow.""" + + service_info = DhcpServiceInfo( + hostname="FYTA HUB", + ip="1.2.3.4", + macaddress="aabbccddeeff", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=service_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + await user_step(hass, result["flow_id"], mock_setup_entry) diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 6d521b1f2c8..10f23759fae 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -66,10 +66,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'bluetooth', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, @@ -223,10 +227,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 71e0afdc495..890edc00482 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '123', 'version': 1, diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index f52e47688e8..40ed22195d5 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index edbbdb1ba28..1ecedbd1173 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'import', + 'subentries': list([ + ]), 'title': '1234', 'unique_id': '1234', 'version': 1, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index ea30f89e0ef..2b0bfd82908 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -491,7 +491,7 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header) -> N assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) bed = hass_fixture.states.get("light.bed_light") - assert bed.attributes.get(light.ATTR_COLOR_TEMP) == 212 + assert bed.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 4700 assert hass_fixture.states.get("switch.decorative_lights").state == "off" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index f1b7108c348..a1c2ba1b3d4 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -402,7 +402,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light.async_write_ha_state() light2 = DemoLight( - None, "Another Light", state=True, hs_color=(180, 75), ct=400, brightness=78 + None, "Another Light", state=True, hs_color=(180, 75), ct=2500, brightness=78 ) light2.hass = hass light2.entity_id = "light.another_light" @@ -410,7 +410,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light2._attr_name = "Another Light" light2.async_write_ha_state() - light3 = DemoLight(None, "Color temp Light", state=True, ct=400, brightness=200) + light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) light3.hass = hass light3.entity_id = "light.color_temp_light" light3._attr_device_info = None @@ -1450,7 +1450,7 @@ async def test_sync_message_recovery( "light.bad_light", "on", { - "min_mireds": "badvalue", + "max_color_temp_kelvin": "badvalue", "supported_color_modes": ["color_temp"], }, ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 1e42edf8e7b..d269b5ff0d7 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -77,7 +77,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.core_config import async_process_ha_core_config -from homeassistant.util import color, dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -431,7 +431,9 @@ async def test_dock_vacuum(hass: HomeAssistant) -> None: assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.DockTrait.supported(vacuum.DOMAIN, 0, None, None) - trt = trait.DockTrait(hass, State("vacuum.bla", vacuum.STATE_IDLE), BASIC_CONFIG) + trt = trait.DockTrait( + hass, State("vacuum.bla", vacuum.VacuumActivity.IDLE), BASIC_CONFIG + ) assert trt.sync_attributes() == {} @@ -454,7 +456,7 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_IDLE, + vacuum.VacuumActivity.IDLE, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.LOCATE}, ), BASIC_CONFIG, @@ -485,7 +487,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_DOCKED, + vacuum.VacuumActivity.DOCKED, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 100, @@ -511,7 +513,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_CLEANING, + vacuum.VacuumActivity.CLEANING, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 20, @@ -551,7 +553,7 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_PAUSED, + vacuum.VacuumActivity.PAUSED, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.PAUSE}, ), BASIC_CONFIG, @@ -868,10 +870,10 @@ async def test_color_setting_temperature_light(hass: HomeAssistant) -> None: "light.bla", STATE_ON, { - light.ATTR_MIN_MIREDS: 200, + light.ATTR_MAX_COLOR_TEMP_KELVIN: 5000, light.ATTR_COLOR_MODE: "color_temp", - light.ATTR_COLOR_TEMP: 300, - light.ATTR_MAX_MIREDS: 500, + light.ATTR_COLOR_TEMP_KELVIN: 3333, + light.ATTR_MIN_COLOR_TEMP_KELVIN: 2000, "supported_color_modes": ["color_temp"], }, ), @@ -904,7 +906,7 @@ async def test_color_setting_temperature_light(hass: HomeAssistant) -> None: assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: "light.bla", - light.ATTR_COLOR_TEMP: color.color_temperature_kelvin_to_mired(2857), + light.ATTR_COLOR_TEMP_KELVIN: 2857, } @@ -922,9 +924,9 @@ async def test_color_light_temperature_light_bad_temp(hass: HomeAssistant) -> No "light.bla", STATE_ON, { - light.ATTR_MIN_MIREDS: 200, - light.ATTR_COLOR_TEMP: 0, - light.ATTR_MAX_MIREDS: 500, + light.ATTR_MAX_COLOR_TEMP_KELVIN: 5000, + light.ATTR_COLOR_TEMP_KELVIN: 0, + light.ATTR_MIN_COLOR_TEMP_KELVIN: 2000, }, ), BASIC_CONFIG, diff --git a/tests/components/google_tasks/conftest.py b/tests/components/google_tasks/conftest.py index 7db78af6232..e519cac9bdc 100644 --- a/tests/components/google_tasks/conftest.py +++ b/tests/components/google_tasks/conftest.py @@ -1,10 +1,12 @@ """Test fixtures for Google Tasks.""" from collections.abc import Awaitable, Callable +import json import time from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch +from httplib2 import Response import pytest from homeassistant.components.application_credentials import ( @@ -24,6 +26,14 @@ FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" +TASK_LIST = { + "id": "task-list-id-1", + "title": "My tasks", +} +LIST_TASK_LIST_RESPONSE = { + "items": [TASK_LIST], +} + @pytest.fixture def platforms() -> list[Platform]: @@ -89,3 +99,31 @@ async def mock_integration_setup( return result return run + + +@pytest.fixture(name="api_responses") +def mock_api_responses() -> list[dict | list]: + """Fixture forcreate_response_object API responses to return during test.""" + return [] + + +def create_response_object(api_response: dict | list) -> tuple[Response, bytes]: + """Create an http response.""" + return ( + Response({"Content-Type": "application/json"}), + json.dumps(api_response).encode(), + ) + + +@pytest.fixture(name="response_handler") +def mock_response_handler(api_responses: list[dict | list]) -> list: + """Create a mock http2lib response handler.""" + return [create_response_object(api_response) for api_response in api_responses] + + +@pytest.fixture +def mock_http_response(response_handler: list | Callable) -> Mock: + """Fixture to fake out http2lib responses.""" + + with patch("httplib2.Http.request", side_effect=response_handler) as mock_response: + yield mock_response diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 76611ba4a31..f32441354fc 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -15,7 +15,7 @@ ) # --- # name: test_create_todo_list_item[due].1 - '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00+00:00", "notes": null}' # --- # name: test_create_todo_list_item[summary] tuple( @@ -137,7 +137,7 @@ ) # --- # name: test_partial_update[due_date].1 - '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' + '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00+00:00", "notes": null}' # --- # name: test_partial_update[empty_description] tuple( @@ -166,6 +166,33 @@ # name: test_partial_update_status[api_responses0].1 '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- +# name: test_update_due_date[api_responses0-America/Regina] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_due_date[api_responses0-America/Regina].1 + '{"title": "Water", "status": "needsAction", "due": "2024-12-05T00:00:00+00:00", "notes": null}' +# --- +# name: test_update_due_date[api_responses0-Asia/Tokyo] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_due_date[api_responses0-Asia/Tokyo].1 + '{"title": "Water", "status": "needsAction", "due": "2024-12-05T00:00:00+00:00", "notes": null}' +# --- +# name: test_update_due_date[api_responses0-UTC] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_due_date[api_responses0-UTC].1 + '{"title": "Water", "status": "needsAction", "due": "2024-12-05T00:00:00+00:00", "notes": null}' +# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py index 1fe0e4a0c36..9ad8c887a66 100644 --- a/tests/components/google_tasks/test_init.py +++ b/tests/components/google_tasks/test_init.py @@ -2,8 +2,12 @@ from collections.abc import Awaitable, Callable import http +from http import HTTPStatus import time +from unittest.mock import Mock +from aiohttp import ClientError +from httplib2 import Response import pytest from homeassistant.components.google_tasks import DOMAIN @@ -11,15 +15,19 @@ from homeassistant.components.google_tasks.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import LIST_TASK_LIST_RESPONSE + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.parametrize("api_responses", [[LIST_TASK_LIST_RESPONSE]]) async def test_setup( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, + mock_http_response: Mock, ) -> None: """Test successful setup and unload.""" assert config_entry.state is ConfigEntryState.NOT_LOADED @@ -35,12 +43,14 @@ async def test_setup( @pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +@pytest.mark.parametrize("api_responses", [[LIST_TASK_LIST_RESPONSE]]) async def test_expired_token_refresh_success( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, setup_credentials: None, + mock_http_response: Mock, ) -> None: """Test expired token is refreshed.""" @@ -63,20 +73,28 @@ async def test_expired_token_refresh_success( @pytest.mark.parametrize( - ("expires_at", "status", "expected_state"), + ("expires_at", "status", "exc", "expected_state"), [ ( time.time() - 3600, http.HTTPStatus.UNAUTHORIZED, + None, ConfigEntryState.SETUP_ERROR, ), ( time.time() - 3600, http.HTTPStatus.INTERNAL_SERVER_ERROR, + None, + ConfigEntryState.SETUP_RETRY, + ), + ( + time.time() - 3600, + None, + ClientError("error"), ConfigEntryState.SETUP_RETRY, ), ], - ids=["unauthorized", "internal_server_error"], + ids=["unauthorized", "internal_server_error", "client_error"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, @@ -84,7 +102,8 @@ async def test_expired_token_refresh_failure( aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, setup_credentials: None, - status: http.HTTPStatus, + status: http.HTTPStatus | None, + exc: Exception | None, expected_state: ConfigEntryState, ) -> None: """Test failure while refreshing token with a transient error.""" @@ -93,8 +112,28 @@ async def test_expired_token_refresh_failure( aioclient_mock.post( OAUTH2_TOKEN, status=status, + exc=exc, ) await integration_setup() assert config_entry.state is expected_state + + +@pytest.mark.parametrize( + "response_handler", + [ + ([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]), + ], +) +async def test_setup_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test an error returned by the server when setting up the platform.""" + + assert not await integration_setup() + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index b0ee135d4a9..c713b9fd44f 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import json from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import Mock from httplib2 import Response import pytest @@ -23,16 +23,11 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import LIST_TASK_LIST_RESPONSE, create_response_object + from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" -ITEM = { - "id": "task-list-id-1", - "title": "My tasks", -} -LIST_TASK_LIST_RESPONSE = { - "items": [ITEM], -} EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { "items": [], @@ -149,20 +144,6 @@ async def ws_get_items( return get -@pytest.fixture(name="api_responses") -def mock_api_responses() -> list[dict | list]: - """Fixture for API responses to return during test.""" - return [] - - -def create_response_object(api_response: dict | list) -> tuple[Response, bytes]: - """Create an http response.""" - return ( - Response({"Content-Type": "application/json"}), - json.dumps(api_response).encode(), - ) - - def create_batch_response_object( content_ids: list[str], api_responses: list[dict | list | Response | None] ) -> tuple[Response, bytes]: @@ -225,20 +206,13 @@ def create_batch_response_handler( return _handler -@pytest.fixture(name="response_handler") -def mock_response_handler(api_responses: list[dict | list]) -> list: - """Create a mock http2lib response handler.""" - return [create_response_object(api_response) for api_response in api_responses] - - @pytest.fixture(autouse=True) -def mock_http_response(response_handler: list | Callable) -> Mock: - """Fixture to fake out http2lib responses.""" - - with patch("httplib2.Http.request", side_effect=response_handler) as mock_response: - yield mock_response +def setup_http_response(mock_http_response: Mock) -> None: + """Fixture to load the http response mock.""" + return +@pytest.mark.parametrize("timezone", ["America/Regina", "UTC", "Asia/Tokyo"]) @pytest.mark.parametrize( "api_responses", [ @@ -251,7 +225,7 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", - "due": "2023-11-18T00:00:00+00:00", + "due": "2023-11-18T00:00:00Z", }, { "id": "task-2", @@ -271,8 +245,10 @@ async def test_get_items( integration_setup: Callable[[], Awaitable[bool]], hass_ws_client: WebSocketGenerator, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + timezone: str, ) -> None: """Test getting todo list items.""" + await hass.config.async_set_time_zone(timezone) assert await integration_setup() @@ -300,29 +276,6 @@ async def test_get_items( assert state.state == "1" -@pytest.mark.parametrize( - "response_handler", - [ - ([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]), - ], -) -async def test_list_items_server_error( - hass: HomeAssistant, - setup_credentials: None, - integration_setup: Callable[[], Awaitable[bool]], - hass_ws_client: WebSocketGenerator, - ws_get_items: Callable[[], Awaitable[dict[str, str]]], -) -> None: - """Test an error returned by the server when setting up the platform.""" - - assert await integration_setup() - - await hass_ws_client(hass) - - state = hass.states.get("todo.my_tasks") - assert state is None - - @pytest.mark.parametrize( "api_responses", [ @@ -484,6 +437,39 @@ async def test_update_todo_list_item( assert call.kwargs.get("body") == snapshot +@pytest.mark.parametrize("timezone", ["America/Regina", "UTC", "Asia/Tokyo"]) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) +async def test_update_due_date( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, + timezone: str, +) -> None: + """Test for updating the due date of a To-do item and timezone.""" + await hass.config.async_set_time_zone(timezone) + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + {ATTR_ITEM: "some-task-id", ATTR_DUE_DATE: "2024-12-5"}, + target={ATTR_ENTITY_ID: "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + @pytest.mark.parametrize( "api_responses", [ diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index af8556b5450..91604d663b3 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -12,7 +12,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, @@ -792,19 +791,19 @@ async def test_emulated_color_temp_group(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_group", ATTR_COLOR_TEMP: 200}, + {ATTR_ENTITY_ID: "light.light_group", ATTR_COLOR_TEMP_KELVIN: 5000}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("light.test1") assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 200 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5000 assert ATTR_HS_COLOR in state.attributes state = hass.states.get("light.test2") assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 200 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5000 assert ATTR_HS_COLOR in state.attributes state = hass.states.get("light.test3") diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index faba2103000..4487d0b6ac6 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -42,6 +42,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "valve_controller": { diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 7784b9c7f49..a4942063612 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -532,6 +532,56 @@ "updatedAt": "2024-07-07T17:51:53.266Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "frequency": "monthly", + "everyX": 1, + "streak": 1, + "nextDue": [ + "2024-12-14T23:00:00.000Z", + "2025-01-18T23:00:00.000Z", + "2025-02-15T23:00:00.000Z", + "2025-03-15T23:00:00.000Z", + "2025-04-19T23:00:00.000Z", + "2025-05-17T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Arbeite an einem kreativen Projekt", + "notes": "Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-09-20T23:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [3], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T23:47:29.986Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" } ], "notifications": [ diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index a498de910ef..ed41a306a03 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -55,7 +55,8 @@ "e97659e0-2c42-4599-a7bb-00282adc410d", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index c2f9c8e83c9..5e010a33c84 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -1,5 +1,21 @@ # serializer version: 1 -# name: test_api_events[calendar.test_user_dailies] +# name: test_api_events[date range in the past-calendar.test_user_dailies] + list([ + ]) +# --- +# name: test_api_events[date range in the past-calendar.test_user_daily_reminders] + list([ + ]) +# --- +# name: test_api_events[date range in the past-calendar.test_user_to_do_reminders] + list([ + ]) +# --- +# name: test_api_events[date range in the past-calendar.test_user_to_do_s] + list([ + ]) +# --- +# name: test_api_events[default date range-calendar.test_user_dailies] list([ dict({ 'description': 'Klicke um Deinen Terminplan festzulegen!', @@ -577,7 +593,7 @@ }), ]) # --- -# name: test_api_events[calendar.test_user_daily_reminders] +# name: test_api_events[default date range-calendar.test_user_daily_reminders] list([ dict({ 'description': 'Klicke um Deinen Terminplan festzulegen!', @@ -819,7 +835,7 @@ }), ]) # --- -# name: test_api_events[calendar.test_user_to_do_reminders] +# name: test_api_events[default date range-calendar.test_user_to_do_reminders] list([ dict({ 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', @@ -837,7 +853,7 @@ }), ]) # --- -# name: test_api_events[calendar.test_user_to_do_s] +# name: test_api_events[default date range-calendar.test_user_to_do_s] list([ dict({ 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index bb9371a4c68..0d5f07d9a6c 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -615,6 +615,66 @@ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10, }), + dict({ + '_id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-10-10T15:57:14.304Z', + 'daysOfMonth': list([ + ]), + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + ]), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00.000Z', + '2025-01-18T23:00:00.000Z', + '2025-02-15T23:00:00.000Z', + '2025-03-15T23:00:00.000Z', + '2025-04-19T23:00:00.000Z', + '2025-05-17T23:00:00.000Z', + ]), + 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-09-20T23:00:00.000Z', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', + 'updatedAt': '2024-11-27T23:47:29.986Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 3, + ]), + 'yesterDaily': True, + }), ]), 'user': dict({ 'api_user': 'test-api-user', @@ -695,6 +755,7 @@ '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + '6e53f1f5-a315-4edd-984d-8d762e4a08ef', ]), 'habits': list([ '1d147de6-5c02-4740-8e2f-71d3015a37f4', diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 28dd7eb8c43..7e72d486276 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -226,6 +226,45 @@ 'value': -2.9663035443712333, 'yester_daily': True, }), + '6e53f1f5-a315-4edd-984d-8d762e4a08ef': dict({ + 'created_at': '2024-10-10T15:57:14.304Z', + 'every_x': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'next_due': list([ + '2024-12-14T23:00:00.000Z', + '2025-01-18T23:00:00.000Z', + '2025-02-15T23:00:00.000Z', + '2025-03-15T23:00:00.000Z', + '2025-04-19T23:00:00.000Z', + '2025-05-17T23:00:00.000Z', + ]), + 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'start_date': '2024-09-20T23:00:00.000Z', + 'streak': 1, + 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', + 'value': -0.9215181434950852, + 'weeks_of_month': list([ + 3, + ]), + 'yester_daily': True, + }), 'f2c85972-1a19-4426-bc6d-ce3337b9d99f': dict({ 'created_at': '2024-07-07T17:51:53.266Z', 'every_x': 1, @@ -270,7 +309,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '4', }) # --- # name: test_sensors[sensor.test_user_display_name-entry] diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 79eca9dbbb0..8c49cad5436 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -42,6 +42,13 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'due': '2024-12-14', + 'status': 'needs_action', + 'summary': 'Arbeite an einem kreativen Projekt', + 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + }), ]), }), }) @@ -137,7 +144,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '3', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index 979cefef923..09cc1c9d373 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -1,6 +1,7 @@ """Tests for Habitica button platform.""" from collections.abc import Generator +from datetime import timedelta from http import HTTPStatus import re from unittest.mock import patch @@ -15,10 +16,16 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util from .conftest import mock_called_with -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -340,3 +347,65 @@ async def test_button_unavailable( for entity_id in entity_ids: assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +async def test_class_change( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test removing and adding skills after class change.""" + mage_skills = [ + "button.test_user_chilling_frost", + "button.test_user_earthquake", + "button.test_user_ethereal_surge", + ] + healer_skills = [ + "button.test_user_healing_light", + "button.test_user_protective_aura", + "button.test_user_searing_brightness", + "button.test_user_blessing", + ] + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/user", + json=load_json_object_fixture("wizard_fixture.json", DOMAIN), + ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/tasks/user", + params={"type": "completedTodos"}, + json=load_json_object_fixture("completed_todos.json", DOMAIN), + ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/tasks/user", + json=load_json_object_fixture("tasks.json", DOMAIN), + ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + for skill in mage_skills: + assert hass.states.get(skill) + + aioclient_mock._mocks.pop(0) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/user", + json=load_json_object_fixture("healer_fixture.json", DOMAIN), + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=60)) + await hass.async_block_till_done() + + for skill in mage_skills: + assert not hass.states.get(skill) + + for skill in healer_skills: + assert hass.states.get(skill) diff --git a/tests/components/habitica/test_calendar.py b/tests/components/habitica/test_calendar.py index a6cdb1a9306..ff3ffbeb80d 100644 --- a/tests/components/habitica/test_calendar.py +++ b/tests/components/habitica/test_calendar.py @@ -59,6 +59,17 @@ async def test_calendar_platform( "calendar.test_user_to_do_reminders", ], ) +@pytest.mark.parametrize( + ("start_date", "end_date"), + [ + ("2024-08-29", "2024-10-08"), + ("2023-08-01", "2023-08-02"), + ], + ids=[ + "default date range", + "date range in the past", + ], +) @pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") @pytest.mark.usefixtures("mock_habitica") async def test_api_events( @@ -67,6 +78,8 @@ async def test_api_events( config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, entity: str, + start_date: str, + end_date: str, ) -> None: """Test calendar event.""" @@ -76,7 +89,7 @@ async def test_api_events( client = await hass_client() response = await client.get( - f"/api/calendars/{entity}?start=2024-08-29&end=2024-10-08" + f"/api/calendars/{entity}?start={start_date}&end={end_date}" ) assert await response.json() == snapshot diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py new file mode 100644 index 00000000000..3e928bc996b --- /dev/null +++ b/tests/components/hassio/test_backup.py @@ -0,0 +1,465 @@ +"""Test supervisor backup functionality.""" + +from collections.abc import AsyncGenerator, Generator +from datetime import datetime +from io import StringIO +import os +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiohasupervisor.models import backups as supervisor_backups +import pytest + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .test_init import MOCK_ENVIRON + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +TEST_BACKUP = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=["share"], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=None, + locations={None}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP.compressed, + date=TEST_BACKUP.date, + extra=None, + folders=["share"], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP.location, + locations=TEST_BACKUP.locations, + name=TEST_BACKUP.name, + protected=TEST_BACKUP.protected, + repositories=[], + size=TEST_BACKUP.size, + size_bytes=TEST_BACKUP.size_bytes, + slug=TEST_BACKUP.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP.type, +) + + +@pytest.fixture(autouse=True) +def fixture_supervisor_environ() -> Generator[None]: + """Mock os environ for supervisor.""" + with patch.dict(os.environ, MOCK_ENVIRON): + yield + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> AsyncGenerator[None]: + """Set up Backup integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=True), + patch("homeassistant.components.backup.backup.is_hassio", return_value=True), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + yield + + +@pytest.mark.usefixtures("hassio_client") +async def test_agent_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "hassio.local"}], + } + + +@pytest.mark.usefixtures("hassio_client") +async def test_agent_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [ + { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agent_ids": ["hassio.local"], + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 1048576, + "with_strategy_settings": False, + } + ] + + +@pytest.mark.usefixtures("hassio_client") +async def test_agent_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "abc123" + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( + iter((b"backup data",)) + ) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +@pytest.mark.usefixtures("hassio_client") +async def test_agent_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + backup_id = "test-backup" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + + supervisor_client.backups.reload.assert_not_called() + with ( + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("shutil.copy"), + ): + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=hassio.local", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + supervisor_client.backups.reload.assert_not_called() + + +@pytest.mark.usefixtures("hassio_client") +async def test_agent_delete_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + supervisor_client.backups.remove_backup.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ("event_data", "mount_info_calls"), + [ + ( + { + "event": "job", + "data": {"name": "mount_manager_create_mount", "done": True}, + }, + 1, + ), + ( + { + "event": "job", + "data": {"name": "mount_manager_create_mount", "done": False}, + }, + 0, + ), + ( + { + "event": "job", + "data": {"name": "mount_manager_remove_mount", "done": True}, + }, + 1, + ), + ( + { + "event": "job", + "data": {"name": "mount_manager_remove_mount", "done": False}, + }, + 0, + ), + ({"event": "job", "data": {"name": "other_job", "done": True}}, 0), + ( + { + "event": "other_event", + "data": {"name": "mount_manager_remove_mount", "done": True}, + }, + 0, + ), + ], +) +async def test_agents_notify_on_mount_added_removed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + event_data: dict[str, Any], + mount_info_calls: int, +) -> None: + """Test the listener is called when mounts are added or removed.""" + client = await hass_ws_client(hass) + assert supervisor_client.mounts.info.call_count == 1 + assert supervisor_client.mounts.info.call_args[0] == () + supervisor_client.mounts.info.reset_mock() + + await client.send_json_auto_id({"type": "supervisor/event", "data": event_data}) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + assert supervisor_client.mounts.info.call_count == mount_info_calls + + +@pytest.mark.usefixtures("hassio_client") +async def test_reader_writer_create( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + supervisor_backups.PartialBackupOptions( + addons=None, + background=True, + compressed=True, + folders=None, + homeassistant_exclude_database=False, + homeassistant=True, + location={None}, + name="Test", + password=None, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "completed", + } + + +@pytest.mark.usefixtures("hassio_client") +async def test_reader_writer_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + password=None, + ), + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + +@pytest.mark.parametrize( + ("parameters", "expected_error"), + [ + ( + {"restore_database": False}, + "Cannot restore Home Assistant without database", + ), + ( + {"restore_homeassistant": False}, + "Cannot restore database without Home Assistant", + ), + ], +) +@pytest.mark.usefixtures("hassio_client") +async def test_reader_writer_restore_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test trigger restore.""" + client = await hass_ws_client(hass) + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + default_parameters = { + "type": "backup/restore", + "agent_id": "hassio.local", + "backup_id": "abc123", + } + + await client.send_json_auto_id(default_parameters | parameters) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": expected_error, + } diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index a12f4c610ad..9ea3341304a 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -27,7 +27,10 @@ from tests.common import MockConfigEntry def config_entry_fixture(): """Create a mock HEOS config entry.""" return MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="Controller (127.0.0.1)" + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + title="HEOS System (via 127.0.0.1)", + unique_id=DOMAIN, ) @@ -164,6 +167,25 @@ def discovery_data_fixture() -> dict: ) +@pytest.fixture(name="discovery_data_bedroom") +def discovery_data_fixture_bedroom() -> dict: + """Return mock discovery data for testing.""" + return ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.2:60006/upnp/desc/aios_device/aios_device.xml", + upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Bedroom", + ssdp.ATTR_UPNP_MANUFACTURER: "Denon", + ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ssdp.ATTR_UPNP_SERIAL: None, + ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + }, + ) + + @pytest.fixture(name="quick_selects") def quick_selects_fixture() -> dict[int, str]: """Create a dict of quick selects for testing.""" diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 7b737d7bb4b..38382a81794 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,14 +1,10 @@ """Tests for the Heos config flow module.""" -from unittest.mock import patch -from urllib.parse import urlparse - from pyheos import HeosError from homeassistant.components import heos, ssdp -from homeassistant.components.heos.config_flow import HeosFlowHandler -from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +from homeassistant.components.heos.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,18 +13,20 @@ from homeassistant.data_entry_flow import FlowResultType async def test_flow_aborts_already_setup(hass: HomeAssistant, config_entry) -> None: """Test flow aborts when entry already setup.""" config_entry.add_to_hass(hass) - flow = HeosFlowHandler() - flow.hass = hass - result = await flow.async_step_user() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" async def test_no_host_shows_form(hass: HomeAssistant) -> None: """Test form is shown when host not provided.""" - flow = HeosFlowHandler() - flow.hass = hass - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} @@ -45,73 +43,69 @@ async def test_cannot_connect_shows_error_form(hass: HomeAssistant, controller) assert result["errors"][CONF_HOST] == "cannot_connect" assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() async def test_create_entry_when_host_valid(hass: HomeAssistant, controller) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} - with patch("homeassistant.components.heos.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN - assert result["title"] == "Controller (127.0.0.1)" - assert result["data"] == data - assert controller.connect.call_count == 1 - assert controller.disconnect.call_count == 1 + + result = await hass.config_entries.flow.async_init( + heos.DOMAIN, context={"source": SOURCE_USER}, data=data + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["data"] == data + assert controller.connect.call_count == 2 # Also called in async_setup_entry + assert controller.disconnect.call_count == 1 async def test_create_entry_when_friendly_name_valid( hass: HomeAssistant, controller ) -> None: """Test result type is create entry when friendly name is valid.""" - hass.data[DATA_DISCOVERED_HOSTS] = {"Office (127.0.0.1)": "127.0.0.1"} + hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} data = {CONF_HOST: "Office (127.0.0.1)"} - with patch("homeassistant.components.heos.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN - assert result["title"] == "Controller (127.0.0.1)" - assert result["data"] == {CONF_HOST: "127.0.0.1"} - assert controller.connect.call_count == 1 - assert controller.disconnect.call_count == 1 - assert DATA_DISCOVERED_HOSTS not in hass.data + + result = await hass.config_entries.flow.async_init( + heos.DOMAIN, context={"source": SOURCE_USER}, data=data + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + assert controller.connect.call_count == 2 # Also called in async_setup_entry + assert controller.disconnect.call_count == 1 + assert DOMAIN not in hass.data async def test_discovery_shows_create_form( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo + hass: HomeAssistant, + controller, + discovery_data: ssdp.SsdpServiceInfo, + discovery_data_bedroom: ssdp.SsdpServiceInfo, ) -> None: - """Test discovery shows form to confirm setup and subsequent abort.""" + """Test discovery shows form to confirm setup.""" - await hass.config_entries.flow.async_init( + # Single discovered host shows form for user to finish setup. + result = await hass.config_entries.flow.async_init( heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data ) - await hass.async_block_till_done() - flows_in_progress = hass.config_entries.flow.async_progress() - assert flows_in_progress[0]["context"]["unique_id"] == DOMAIN - assert len(flows_in_progress) == 1 - assert hass.data[DATA_DISCOVERED_HOSTS] == {"Office (127.0.0.1)": "127.0.0.1"} + assert hass.data[DOMAIN] == {"Office (127.0.0.1)": "127.0.0.1"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - port = urlparse(discovery_data.ssdp_location).port - discovery_data.ssdp_location = f"http://127.0.0.2:{port}/" - discovery_data.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" - - await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + # Subsequent discovered hosts append to discovered hosts and abort. + result = await hass.config_entries.flow.async_init( + heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom ) - await hass.async_block_till_done() - flows_in_progress = hass.config_entries.flow.async_progress() - assert flows_in_progress[0]["context"]["unique_id"] == DOMAIN - assert len(flows_in_progress) == 1 - assert hass.data[DATA_DISCOVERED_HOSTS] == { + assert hass.data[DOMAIN] == { "Office (127.0.0.1)": "127.0.0.1", "Bedroom (127.0.0.2)": "127.0.0.2", } + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" async def test_discovery_flow_aborts_already_setup( @@ -119,41 +113,80 @@ async def test_discovery_flow_aborts_already_setup( ) -> None: """Test discovery flow aborts when entry already setup.""" config_entry.add_to_hass(hass) - flow = HeosFlowHandler() - flow.hass = hass - result = await flow.async_step_ssdp(discovery_data) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" -async def test_discovery_sets_the_unique_id( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo +async def test_reconfigure_validates_and_updates_config( + hass: HomeAssistant, config_entry, controller ) -> None: - """Test discovery sets the unique id.""" + """Test reconfigure validates host and successfully updates.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" - port = urlparse(discovery_data.ssdp_location).port - discovery_data.ssdp_location = f"http://127.0.0.2:{port}/" - discovery_data.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" - - await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + # Test reconfigure initially shows form with current host value. + host = next( + key.default() for key in result["data_schema"].schema if key == CONF_HOST ) - await hass.async_block_till_done() - flows_in_progress = hass.config_entries.flow.async_progress() - assert flows_in_progress[0]["context"]["unique_id"] == DOMAIN - assert len(flows_in_progress) == 1 - assert hass.data[DATA_DISCOVERED_HOSTS] == {"Bedroom (127.0.0.2)": "127.0.0.2"} + assert host == "127.0.0.1" + assert result["errors"] == {} + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + + # Test reconfigure successfully updates. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + assert controller.connect.call_count == 2 # Also called when entry reloaded + assert controller.disconnect.call_count == 1 + assert config_entry.data == {CONF_HOST: "127.0.0.2"} + assert config_entry.unique_id == DOMAIN + assert result["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT -async def test_import_sets_the_unique_id(hass: HomeAssistant, controller) -> None: - """Test import sets the unique id.""" +async def test_reconfigure_cannot_connect_recovers( + hass: HomeAssistant, config_entry, controller +) -> None: + """Test reconfigure cannot connect and recovers.""" + controller.connect.side_effect = HeosError() + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" - with patch("homeassistant.components.heos.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "127.0.0.2"}, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + host = next( + key.default() for key in result["data_schema"].schema if key == CONF_HOST + ) + assert host == "127.0.0.2" + assert result["errors"][CONF_HOST] == "cannot_connect" + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + + # Test reconfigure recovers and successfully updates. + controller.connect.side_effect = None + controller.connect.reset_mock() + controller.disconnect.reset_mock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + assert controller.connect.call_count == 2 # Also called when entry reloaded + assert controller.disconnect.call_count == 1 + assert config_entry.data == {CONF_HOST: "127.0.0.2"} + assert config_entry.unique_id == DOMAIN + assert result["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 04b745135d4..8d2e3b68a22 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -13,40 +13,11 @@ from homeassistant.components.heos import ( async_unload_entry, ) from homeassistant.components.heos.const import DOMAIN -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component -async def test_async_setup_creates_entry(hass: HomeAssistant, config) -> None: - """Test component setup creates entry from config.""" - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.title == "Controller (127.0.0.1)" - assert entry.data == {CONF_HOST: "127.0.0.1"} - assert entry.unique_id == DOMAIN - - -async def test_async_setup_updates_entry( - hass: HomeAssistant, config_entry, config, controller -) -> None: - """Test component setup updates entry from config.""" - config[DOMAIN][CONF_HOST] = "127.0.0.2" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.title == "Controller (127.0.0.2)" - assert entry.data == {CONF_HOST: "127.0.0.2"} - assert entry.unique_id == DOMAIN - - async def test_async_setup_returns_true( hass: HomeAssistant, config_entry, config ) -> None: diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index e5dba49dcc1..8749954c364 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -25,52 +25,6 @@ MFA_RESEND_CODE = "0000" MFA_INVALID_CODE = "HIVE" -async def test_import_flow(hass: HomeAssistant) -> None: - """Check import flow.""" - - with ( - patch( - "homeassistant.components.hive.config_flow.Auth.login", - return_value={ - "ChallengeName": "SUCCESS", - "AuthenticationResult": { - "RefreshToken": "mock-refresh-token", - "AccessToken": "mock-access-token", - }, - }, - ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.hive.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == USERNAME - assert result["data"] == { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - "tokens": { - "AuthenticationResult": { - "AccessToken": "mock-access-token", - "RefreshToken": "mock-refresh-token", - }, - "ChallengeName": "SUCCESS", - }, - } - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_user_flow(hass: HomeAssistant) -> None: """Test the user flow.""" result = await hass.config_entries.flow.async_init( @@ -91,9 +45,6 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, }, ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.hive.async_setup_entry", return_value=True, @@ -119,7 +70,6 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -185,9 +135,6 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: "mock-device-password", ], ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.hive.async_setup_entry", return_value=True, @@ -220,7 +167,6 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: ], } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -462,9 +408,6 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: "mock-device-password", ], ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.hive.async_setup_entry", return_value=True, @@ -493,7 +436,6 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: "mock-device-password", ], } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index d2eff43e071..2ac8c851e1b 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -183,10 +183,15 @@ def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: mock.get_programs_available.side_effect = HomeConnectError mock.start_program.side_effect = HomeConnectError mock.select_program.side_effect = HomeConnectError + mock.pause_program.side_effect = HomeConnectError mock.stop_program.side_effect = HomeConnectError + mock.set_options_active_program.side_effect = HomeConnectError + mock.set_options_selected_program.side_effect = HomeConnectError mock.get_status.side_effect = HomeConnectError mock.get_settings.side_effect = HomeConnectError mock.set_setting.side_effect = HomeConnectError + mock.set_setting.side_effect = HomeConnectError + mock.execute_command.side_effect = HomeConnectError return mock diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 7c4f73b6f0a..69601efb42d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -29,6 +29,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from script.hassfest.translations import RE_TRANSLATION_KEY @@ -290,8 +291,40 @@ async def test_services( ) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) @pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( + service_call: list[dict[str, Any]], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + problematic_appliance: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + get_appliances.return_value = [problematic_appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, problematic_appliance.haId)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +@pytest.mark.usefixtures("bypass_throttle") +async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[], Awaitable[bool]], @@ -299,7 +332,7 @@ async def test_services_exception( get_appliances: MagicMock, appliance: Mock, ) -> None: - """Raise a ValueError when device id does not match.""" + """Raise a ServiceValidationError when device id does not match.""" get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup() @@ -309,7 +342,7 @@ async def test_services_exception( service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - with pytest.raises(AssertionError): + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 33d78cd6c9f..56eeb4177b1 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -184,6 +184,7 @@ async def test_turn_on_skips_domains_without_service( # because by mocking out the call service method, we mock out all # So we mimic how the service registry calls services service_call = ha.ServiceCall( + hass, "homeassistant", "turn_on", {"entity_id": ["light.test", "sensor.bla", "binary_sensor.blub", "light.bla"]}, diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index a45e4988c36..fb059b93a13 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -20,8 +20,8 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -1391,8 +1391,8 @@ async def test_light_min_max_mireds(hass: HomeAssistant, hk_driver) -> None: { ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, - ATTR_MAX_MIREDS: 500.5, - ATTR_MIN_MIREDS: 153.5, + ATTR_MIN_COLOR_TEMP_KELVIN: 1999, + ATTR_MAX_COLOR_TEMP_KELVIN: 6499, }, ) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 9b708f18b8a..0d19763e4c7 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -26,8 +26,7 @@ from homeassistant.components.vacuum import ( SERVICE_START, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_CLEANING, - STATE_DOCKED, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -295,7 +294,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( hass.states.async_set( entity_id, - STATE_CLEANING, + VacuumActivity.CLEANING, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -306,7 +305,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( hass.states.async_set( entity_id, - STATE_DOCKED, + VacuumActivity.DOCKED, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index b96da507adf..2bd5e7faf75 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -1474,7 +1474,7 @@ 'state': dict({ 'attributes': dict({ 'changed_by': None, - 'code_arm_required': True, + 'code_arm_required': False, 'code_format': None, 'friendly_name': 'Aqara-Hub-E1-00A0 Security System', 'supported_features': , @@ -1848,7 +1848,7 @@ 'state': dict({ 'attributes': dict({ 'changed_by': None, - 'code_arm_required': True, + 'code_arm_required': False, 'code_format': None, 'friendly_name': 'Aqara Hub-1563 Security System', 'supported_features': , diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 1e9f023fc46..3ab9dc82e41 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -6,6 +6,7 @@ from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.components.alarm_control_panel import ATTR_CODE_ARM_REQUIRED from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -106,6 +107,7 @@ async def test_switch_read_alarm_state( state = await helper.poll_and_get_state() assert state.state == "armed_home" assert state.attributes["battery_level"] == 50 + assert state.attributes[ATTR_CODE_ARM_REQUIRED] is False await helper.async_update( ServicesTypes.SECURITY_SYSTEM, diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index 0a301fc3941..71e70f3a153 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -74,10 +78,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -118,10 +126,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Energy Socket', 'unique_id': 'HWE-SKT_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Energy Socket', 'type': , 'version': 1, @@ -158,10 +170,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index a91c87722d1..c5de96cbf8f 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -6468,7 +6468,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:state] @@ -6476,7 +6476,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', @@ -10228,7 +10228,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_water_usage:state] @@ -10236,7 +10236,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', @@ -13562,7 +13562,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_water_usage:state] @@ -13570,7 +13570,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', @@ -15301,7 +15301,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_water_usage:state] @@ -15309,7 +15309,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index 9c10d5e39e1..ce54863736b 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -6,7 +6,6 @@ import pytest from homeassistant.components.humidifier import ( ATTR_HUMIDITY, - ATTR_MODE, DOMAIN as HUMIDIFIER_DOMAIN, MODE_ECO, MODE_NORMAL, @@ -51,30 +50,6 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: assert humidifier.turn_off.called -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockHumidifierEntity(HumidifierEntity): - _attr_mode = "mode1" - - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockHumidifierEntity() - assert entity.supported_features_compat is HumidifierEntityFeature(1) - assert "MockHumidifierEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "HumidifierEntityFeature.MODES" in caplog.text - caplog.clear() - assert entity.supported_features_compat is HumidifierEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - assert entity.state_attributes[ATTR_MODE] == "mode1" - - async def test_humidity_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index a0bb8302fcc..2dab82451a6 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -71,9 +71,7 @@ 'activity': 'parked_in_cs', 'error_code': 0, 'error_datetime': None, - 'error_datetime_naive': None, 'error_key': None, - 'error_timestamp': 0, 'inactive_reason': 'none', 'is_error_confirmable': False, 'mode': 'main_area', @@ -82,9 +80,7 @@ 'work_area_name': 'Front lawn', }), 'planner': dict({ - 'next_start': 1685991600000, 'next_start_datetime': '2023-06-05T19:00:00+02:00', - 'next_start_datetime_naive': '2023-06-05T19:00:00', 'override': dict({ 'action': 'not_active', }), @@ -141,7 +137,6 @@ 'cutting_height': 50, 'enabled': False, 'last_time_completed': '2024-08-12T05:07:49+02:00', - 'last_time_completed_naive': '2024-08-12T05:07:49', 'name': 'my_lawn', 'progress': 20, }), @@ -149,7 +144,6 @@ 'cutting_height': 50, 'enabled': True, 'last_time_completed': '2024-08-12T07:54:29+02:00', - 'last_time_completed_naive': '2024-08-12T07:54:29', 'name': 'Front lawn', 'progress': 40, }), @@ -157,7 +151,6 @@ 'cutting_height': 25, 'enabled': True, 'last_time_completed': None, - 'last_time_completed_naive': None, 'name': 'Back lawn', 'progress': None, }), @@ -190,6 +183,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Husqvarna Automower of Erika Mustermann', 'unique_id': '123', 'version': 1, diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index a938322414b..2de7fb1da9a 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -56,7 +56,6 @@ def mock_legacy_pydrawise( @pytest.fixture def mock_pydrawise( - mock_auth: AsyncMock, user: User, controller: Controller, zones: list[Zone], diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index e85b1b9b249..4d25fd5840b 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -21,6 +21,7 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User, ) -> None: @@ -46,11 +47,12 @@ async def test_form( CONF_PASSWORD: "__password__", } assert len(mock_setup_entry.mock_calls) == 1 - mock_pydrawise.get_user.assert_called_once_with(fetch_zones=False) + mock_auth.token.assert_awaited_once_with() + mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) async def test_form_api_error( - hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User ) -> None: """Test we handle API errors.""" mock_pydrawise.get_user.side_effect = ClientError("XXX") @@ -71,8 +73,29 @@ async def test_form_api_error( assert result2["type"] is FlowResultType.CREATE_ENTRY -async def test_form_connect_timeout( - hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +async def test_form_auth_connect_timeout( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock +) -> None: + """Test we handle API errors.""" + mock_auth.token.side_effect = TimeoutError + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], data + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + + mock_auth.token.reset_mock(side_effect=True) + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result2["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_client_connect_timeout( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User ) -> None: """Test we handle API errors.""" mock_pydrawise.get_user.side_effect = TimeoutError @@ -94,10 +117,10 @@ async def test_form_connect_timeout( async def test_form_not_authorized_error( - hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle API errors.""" - mock_pydrawise.get_user.side_effect = NotAuthorizedError + mock_auth.token.side_effect = NotAuthorizedError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -109,8 +132,7 @@ async def test_form_not_authorized_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_pydrawise.get_user.reset_mock(side_effect=True) - mock_pydrawise.get_user.return_value = user + mock_auth.token.reset_mock(side_effect=True) result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -118,6 +140,7 @@ async def test_form_not_authorized_error( async def test_reauth( hass: HomeAssistant, user: User, + mock_auth: AsyncMock, mock_pydrawise: AsyncMock, ) -> None: """Test that re-authorization works.""" diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 494980ba4ce..f15fc706d7e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'River Name (Station Name)', 'unique_id': '123', 'version': 1, diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index f17547a1445..b00e3a638c8 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from incomfortclient import DisplayCode import pytest -from homeassistant.components.incomfort import DOMAIN +from homeassistant.components.incomfort.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 7a942dab817..287fd85715f 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -6,8 +6,8 @@ from aiohttp import ClientResponseError from incomfortclient import IncomfortError, InvalidHeaterList import pytest -from homeassistant.components.incomfort import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.components.incomfort.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,50 +38,6 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 -async def test_import( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock -) -> None: - """Test we van import from YAML.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" - assert result["data"] == MOCK_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("exc", "abort_reason"), - [ - (IncomfortError(ClientResponseError(None, None, status=401)), "auth_error"), - (IncomfortError(ClientResponseError(None, None, status=404)), "not_found"), - (IncomfortError(ClientResponseError(None, None, status=500)), "unknown"), - (IncomfortError, "unknown"), - (InvalidHeaterList, "no_heaters"), - (ValueError, "unknown"), - (TimeoutError, "timeout_error"), - ], -) -async def test_import_fails( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_incomfort: MagicMock, - exc: Exception, - abort_reason: str, -) -> None: - """Test YAML import fails.""" - mock_incomfort().heaters.side_effect = exc - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == abort_reason - assert len(mock_setup_entry.mock_calls) == 0 - - async def test_entry_already_configured(hass: HomeAssistant) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 20c0f9d8d44..0db9682d0ad 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -466,3 +466,14 @@ async def test_intents_with_no_responses(hass: HomeAssistant) -> None: for intent_name in (intent.INTENT_NEVERMIND, intent.INTENT_RESPOND): response = await intent.async_handle(hass, "test", intent_name, {}) assert not response.speech + + +async def test_intents_respond_intent(hass: HomeAssistant) -> None: + """Test HassRespond intent with a response slot value.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + response = await intent.async_handle( + hass, "test", intent.INTENT_RESPOND, {"response": {"value": "Hello World"}} + ) + assert response.speech["plain"]["speech"] == "Hello World" diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 26c575f0407..39084b9298b 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant import config as hass_config from homeassistant.components.intent_script import DOMAIN -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import ATTR_FRIENDLY_NAME, SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -235,17 +235,31 @@ async def test_intent_script_targets( floor_1 = floor_registry.async_create("first floor") kitchen = area_registry.async_get_or_create("kitchen") area_registry.async_update(kitchen.id, floor_id=floor_1.floor_id) + bathroom = area_registry.async_get_or_create("bathroom") entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" + "light", "demo", "kitchen", suggested_object_id="kitchen" ) entity_registry.async_update_entity("light.kitchen", area_id=kitchen.id) - hass.states.async_set("light.kitchen", "off") + hass.states.async_set( + "light.kitchen", "off", attributes={ATTR_FRIENDLY_NAME: "overhead light"} + ) + entity_registry.async_get_or_create( + "light", "demo", "bathroom", suggested_object_id="bathroom" + ) + entity_registry.async_update_entity("light.bathroom", area_id=bathroom.id) + hass.states.async_set( + "light.bathroom", "off", attributes={ATTR_FRIENDLY_NAME: "overhead light"} + ) response = await intent.async_handle( hass, "test", "Targets", - {"name": {"value": "kitchen"}, "domain": {"value": "light"}}, + { + "name": {"value": "overhead light"}, + "domain": {"value": "light"}, + "preferred_area_id": {"value": "kitchen"}, + }, ) assert len(calls) == 1 assert calls[0].data["targets"] == {"entities": ["light.kitchen"]} diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index f2fa656cb0f..41cfedb0e29 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -358,6 +358,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index eda9c2c5d1d..9091694e6a5 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -7,6 +7,7 @@ from bleak.backends.device import BLEDevice from habluetooth import BluetoothServiceInfoBleak from pynecil import ( DeviceInfoResponse, + LatestRelease, LiveDataResponse, OperatingMode, PowerSource, @@ -114,24 +115,20 @@ def mock_ble_device() -> Generator[MagicMock]: @pytest.fixture(autouse=True) -def mock_githubapi() -> Generator[AsyncMock]: - """Mock aiogithubapi.""" +def mock_ironosupdate() -> Generator[AsyncMock]: + """Mock IronOSUpdate.""" with patch( - "homeassistant.components.iron_os.GitHubAPI", + "homeassistant.components.iron_os.IronOSUpdate", autospec=True, ) as mock_client: client = mock_client.return_value - client.repos.releases.latest = AsyncMock() - - client.repos.releases.latest.return_value.data.html_url = ( - "https://github.com/Ralim/IronOS/releases/tag/v2.22" + client.latest_release.return_value = LatestRelease( + html_url="https://github.com/Ralim/IronOS/releases/tag/v2.22", + name="V2.22 | TS101 & S60 Added | PinecilV2 improved", + tag_name="v2.22", + body="**RELEASE_NOTES**", ) - client.repos.releases.latest.return_value.data.name = ( - "V2.22 | TS101 & S60 Added | PinecilV2 improved" - ) - client.repos.releases.latest.return_value.data.tag_name = "v2.22" - client.repos.releases.latest.return_value.data.body = "**RELEASE_NOTES**" yield client diff --git a/tests/components/iron_os/snapshots/test_binary_sensor.ambr b/tests/components/iron_os/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..17b49c1d687 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.pinecil_soldering_tip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.pinecil_soldering_tip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Soldering tip', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.pinecil_soldering_tip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Pinecil Soldering tip', + }), + 'context': , + 'entity_id': 'binary_sensor.pinecil_soldering_tip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iron_os/test_binary_sensor.py b/tests/components/iron_os/test_binary_sensor.py new file mode 100644 index 00000000000..291fbf80573 --- /dev/null +++ b/tests/components/iron_os/test_binary_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the Pinecil Binary Sensors.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pynecil import LiveDataResponse +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def binary_sensor_only() -> AsyncGenerator[None]: + """Enable only the binary sensor platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_binary_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the Pinecil binary sensor platform.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "ble_device", "mock_pynecil" +) +async def test_tip_on_off( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test tip_connected binary sensor on/off states.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("binary_sensor.pinecil_soldering_tip").state == STATE_ON + + mock_pynecil.get_live_data.return_value = LiveDataResponse( + live_temp=479, + max_tip_temp_ability=460, + ) + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.pinecil_soldering_tip").state == STATE_OFF diff --git a/tests/components/iron_os/test_sensor.py b/tests/components/iron_os/test_sensor.py index 2f79487a7fd..fec111c5799 100644 --- a/tests/components/iron_os/test_sensor.py +++ b/tests/components/iron_os/test_sensor.py @@ -4,13 +4,13 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError +from pynecil import CommunicationError, LiveDataResponse import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.iron_os.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -71,3 +71,34 @@ async def test_sensors_unavailable( ) for entity_entry in entity_entries: assert hass.states.get(entity_entry.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "ble_device", "mock_pynecil" +) +async def test_tip_detection( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + ble_device: MagicMock, +) -> None: + """Test sensor state is unknown when tip is disconnected.""" + + mock_pynecil.get_live_data.return_value = LiveDataResponse( + live_temp=479, + max_tip_temp_ability=460, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + entities = { + "sensor.pinecil_tip_temperature", + "sensor.pinecil_max_tip_temperature", + "sensor.pinecil_raw_tip_voltage", + "sensor.pinecil_tip_resistance", + } + for entity_id in entities: + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/iron_os/test_update.py b/tests/components/iron_os/test_update.py index 7a2650ba7a3..47f3197da0e 100644 --- a/tests/components/iron_os/test_update.py +++ b/tests/components/iron_os/test_update.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, patch -from aiogithubapi import GitHubException +from pynecil import UpdateException import pytest from syrupy.assertion import SnapshotAssertion @@ -26,7 +26,7 @@ async def update_only() -> AsyncGenerator[None]: yield -@pytest.mark.usefixtures("mock_pynecil", "ble_device", "mock_githubapi") +@pytest.mark.usefixtures("mock_pynecil", "ble_device", "mock_ironosupdate") async def test_update( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -60,11 +60,11 @@ async def test_update( async def test_update_unavailable( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_githubapi: AsyncMock, + mock_ironosupdate: AsyncMock, ) -> None: """Test update entity unavailable on error.""" - mock_githubapi.repos.releases.latest.side_effect = GitHubException + mock_ironosupdate.latest_release.side_effect = UpdateException config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/ituran/__init__.py b/tests/components/ituran/__init__.py new file mode 100644 index 00000000000..52fccaad138 --- /dev/null +++ b/tests/components/ituran/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Ituran integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py new file mode 100644 index 00000000000..ef22c90591d --- /dev/null +++ b/tests/components/ituran/conftest.py @@ -0,0 +1,83 @@ +"""Mocks for the Ituran integration.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, PropertyMock, patch + +import pytest + +from homeassistant.components.ituran.const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_PHONE_NUMBER, + DOMAIN, +) + +from .const import MOCK_CONFIG_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ituran.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"Ituran {MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT]}", + domain=DOMAIN, + data={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + CONF_MOBILE_ID: MOCK_CONFIG_DATA[CONF_MOBILE_ID], + }, + unique_id=MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + ) + + +class MockVehicle: + """Mock vehicle.""" + + def __init__(self) -> None: + """Initialize mock vehicle.""" + self.license_plate = "12345678" + self.make = "mock make" + self.model = "mock model" + self.mileage = 1000 + self.speed = 20 + self.gps_coordinates = (25.0, -71.0) + self.address = "Bermuda Triangle" + self.heading = 150 + self.last_update = datetime(2024, 1, 1, 0, 0, 0) + + +@pytest.fixture +def mock_ituran() -> Generator[AsyncMock]: + """Return a mocked PalazzettiClient.""" + with ( + patch( + "homeassistant.components.ituran.coordinator.Ituran", + autospec=True, + ) as ituran, + patch( + "homeassistant.components.ituran.config_flow.Ituran", + new=ituran, + ), + ): + mock_ituran = ituran.return_value + mock_ituran.is_authenticated.return_value = False + mock_ituran.authenticate.return_value = True + mock_ituran.get_vehicles.return_value = [MockVehicle()] + type(mock_ituran).mobile_id = PropertyMock( + return_value=MOCK_CONFIG_DATA[CONF_MOBILE_ID] + ) + + yield mock_ituran diff --git a/tests/components/ituran/const.py b/tests/components/ituran/const.py new file mode 100644 index 00000000000..b566caebbbe --- /dev/null +++ b/tests/components/ituran/const.py @@ -0,0 +1,24 @@ +"""Constants for tests of the Ituran component.""" + +from typing import Any + +from homeassistant.components.ituran.const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_PHONE_NUMBER, + DOMAIN, +) + +MOCK_CONFIG_DATA: dict[str, str] = { + CONF_ID_OR_PASSPORT: "12345678", + CONF_PHONE_NUMBER: "0501234567", + CONF_MOBILE_ID: "0123456789abcdef", +} + +MOCK_CONFIG_ENTRY: dict[str, Any] = { + "domain": DOMAIN, + "entry_id": "1", + "source": "user", + "title": MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + "data": MOCK_CONFIG_DATA, +} diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..3b650f7927f --- /dev/null +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_device_tracker[device_tracker.mock_model-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.mock_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'car', + 'unique_id': '12345678-device_tracker', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.mock_model-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model', + 'gps_accuracy': 0, + 'latitude': 25.0, + 'longitude': -71.0, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.mock_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr new file mode 100644 index 00000000000..1e64ef9e850 --- /dev/null +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_device + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ituran', + '12345678', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'mock make', + 'model': 'mock model', + 'model_id': None, + 'name': 'mock model', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345678', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/ituran/test_config_flow.py b/tests/components/ituran/test_config_flow.py new file mode 100644 index 00000000000..19253103ad7 --- /dev/null +++ b/tests/components/ituran/test_config_flow.py @@ -0,0 +1,254 @@ +"""Test the Ituran config flow.""" + +from unittest.mock import AsyncMock + +from pyituran.exceptions import IturanApiError, IturanAuthError +import pytest + +from homeassistant.components.ituran.const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .const import MOCK_CONFIG_DATA + +from tests.common import MockConfigEntry + + +async def __do_successful_user_step( + hass: HomeAssistant, result: ConfigFlowResult, mock_ituran: AsyncMock +): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + return result + + +async def __do_successful_otp_step( + hass: HomeAssistant, + result: ConfigFlowResult, + mock_ituran: AsyncMock, +): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Ituran {MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT]}" + assert result["data"][CONF_ID_OR_PASSPORT] == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + assert result["data"][CONF_PHONE_NUMBER] == MOCK_CONFIG_DATA[CONF_PHONE_NUMBER] + assert result["data"][CONF_MOBILE_ID] is not None + assert result["result"].unique_id == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + assert len(mock_ituran.is_authenticated.mock_calls) > 0 + assert len(mock_ituran.authenticate.mock_calls) > 0 + + return result + + +async def test_full_user_flow( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await __do_successful_user_step(hass, result, mock_ituran) + await __do_successful_otp_step(hass, result, mock_ituran) + + +async def test_invalid_auth( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test invalid credentials configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_ituran.request_otp.side_effect = IturanAuthError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + mock_ituran.request_otp.side_effect = None + result = await __do_successful_user_step(hass, result, mock_ituran) + await __do_successful_otp_step(hass, result, mock_ituran) + + +async def test_invalid_otp( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test invalid OTP configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await __do_successful_user_step(hass, result, mock_ituran) + + mock_ituran.authenticate.side_effect = IturanAuthError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_otp"} + + mock_ituran.authenticate.side_effect = None + await __do_successful_otp_step(hass, result, mock_ituran) + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [(IturanApiError, "cannot_connect"), (Exception, "unknown")], +) +async def test_errors( + hass: HomeAssistant, + mock_ituran: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test connection errors during configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_ituran.request_otp.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + mock_ituran.request_otp.side_effect = None + result = await __do_successful_user_step(hass, result, mock_ituran) + + mock_ituran.authenticate.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_ituran.authenticate.side_effect = None + await __do_successful_otp_step(hass, result, mock_ituran) + + +async def test_already_authenticated( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test user already authenticated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_ituran.is_authenticated.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Ituran {MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT]}" + assert result["data"][CONF_ID_OR_PASSPORT] == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + assert result["data"][CONF_PHONE_NUMBER] == MOCK_CONFIG_DATA[CONF_PHONE_NUMBER] + assert result["data"][CONF_MOBILE_ID] == MOCK_CONFIG_DATA[CONF_MOBILE_ID] + assert result["result"].unique_id == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + + +async def test_reauth( + hass: HomeAssistant, + mock_ituran: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthenticating.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await __do_successful_user_step(hass, result, mock_ituran) + await __do_successful_otp_step(hass, result, mock_ituran) + + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/ituran/test_device_tracker.py b/tests/components/ituran/test_device_tracker.py new file mode 100644 index 00000000000..7bcb314cde7 --- /dev/null +++ b/tests/components/ituran/test_device_tracker.py @@ -0,0 +1,61 @@ +"""Test the Ituran device_tracker.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_device_tracker( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of device_tracker.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device is marked as unavailable when we can't reach the Ituran service.""" + entity_id = "device_tracker.mock_model" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/ituran/test_init.py b/tests/components/ituran/test_init.py new file mode 100644 index 00000000000..3dfe946cdf9 --- /dev/null +++ b/tests/components/ituran/test_init.py @@ -0,0 +1,113 @@ +"""Tests for the Ituran integration.""" + +from unittest.mock import AsyncMock + +from pyituran.exceptions import IturanApiError, IturanAuthError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, +) -> None: + """Test the Ituran configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the device information.""" + await setup_integration(hass, mock_config_entry) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries == snapshot + + +async def test_remove_stale_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that devices not returned by the service are removed.""" + await setup_integration(hass, mock_config_entry) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.return_value = [] + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 0 + + +async def test_recover_from_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Verify we can recover from service Errors.""" + + await setup_integration(hass, mock_config_entry) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.side_effect = IturanApiError + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.side_effect = IturanAuthError + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.side_effect = None + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py new file mode 100644 index 00000000000..6a738094ae6 --- /dev/null +++ b/tests/components/kitchen_sink/test_backup.py @@ -0,0 +1,215 @@ +"""Test the Kitchen Sink backup platform.""" + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import patch + +import pytest + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def backup_only() -> AsyncGenerator[None]: + """Enable only the backup platform. + + The backup platform is not an entity platform. + """ + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: + """Set up Kitchen Sink integration.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agents": [{"agent_id": "backup.local"}]} + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [ + { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agent_ids": ["kitchen_sink.syncer"], + "backup_id": "abc123", + "database_included": False, + "date": "1970-01-01T00:00:00Z", + "failed_agent_ids": [], + "folders": ["media", "share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Kitchen sink syncer", + "protected": False, + "size": 1234, + "with_strategy_settings": False, + } + ] + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a backup.""" + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123?agent_id=kitchen_sink.syncer") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + hass_supervisor_access_token: str, +) -> None: + """Test agent upload backup.""" + ws_client = await hass_ws_client(hass, hass_supervisor_access_token) + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + + with ( + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + ): + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=kitchen_sink.syncer", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + + await ws_client.send_json_auto_id({"type": "backup/info"}) + response = await ws_client.receive_json() + + assert response["success"] + backup_list = response["result"]["backups"] + assert len(backup_list) == 2 + assert backup_list[1] == { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agent_ids": ["kitchen_sink.syncer"], + "backup_id": "test-backup", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": ["media", "share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0.0, + "with_strategy_settings": False, + } + + +async def test_agent_delete_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert f"Deleted backup {backup_id}" in caplog.text + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + backup_list = response["result"]["backups"] + assert not backup_list diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 08f06684d9a..3a99a7f681d 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -57,6 +57,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "client": { "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 201bbbc971e..640726e2355 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/lacrosse_view/test_config_flow.py b/tests/components/lacrosse_view/test_config_flow.py index 9ca7fb78bdd..f953d9a3841 100644 --- a/tests/components/lacrosse_view/test_config_flow.py +++ b/tests/components/lacrosse_view/test_config_flow.py @@ -30,7 +30,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ), patch( "lacrosse_view.LaCrosse.get_locations", - return_value=[Location(id=1, name="Test")], + return_value=[Location(id="1", name="Test")], ), ): result2 = await hass.config_entries.flow.async_configure( @@ -206,7 +206,7 @@ async def test_already_configured_device( ), patch( "lacrosse_view.LaCrosse.get_locations", - return_value=[Location(id=1, name="Test")], + return_value=[Location(id="1", name="Test")], ), ): result2 = await hass.config_entries.flow.async_configure( @@ -262,7 +262,7 @@ async def test_reauth(hass: HomeAssistant) -> None: patch("lacrosse_view.LaCrosse.login", return_value=True), patch( "lacrosse_view.LaCrosse.get_locations", - return_value=[Location(id=1, name="Test")], + return_value=[Location(id="1", name="Test")], ), ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index d6d59cf9ebc..997fa73604c 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from bleak.backends.device import BLEDevice from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel -from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest @@ -143,7 +143,7 @@ def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: with ( patch( - "homeassistant.components.lamarzocco.coordinator.LaMarzoccoMachine", + "homeassistant.components.lamarzocco.LaMarzoccoMachine", autospec=True, ) as lamarzocco_mock, ): diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index b185557bd08..b1d8140b2ce 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ 'config': dict({ 'backflush_enabled': False, + 'bbw_settings': None, 'boilers': dict({ 'CoffeeBoiler1': dict({ 'current_temperature': 96.5, @@ -44,6 +45,7 @@ }), }), 'prebrew_mode': 'TypeB', + 'scale': None, 'smart_standby': dict({ 'enabled': True, 'minutes': 10, diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index cb6b028bda0..446c8780b62 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -6,7 +6,6 @@ from pylamarzocco.const import FirmwareType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful import pytest from syrupy import SnapshotAssertion -from websockets.protocol import State from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN @@ -175,9 +174,7 @@ async def test_bluetooth_is_set_from_discovery( "homeassistant.components.lamarzocco.async_discovered_service_info", return_value=[service_info], ) as discovery, - patch( - "homeassistant.components.lamarzocco.coordinator.LaMarzoccoMachine" - ) as init_device, + patch("homeassistant.components.lamarzocco.LaMarzoccoMachine") as init_device, ): await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() @@ -200,7 +197,7 @@ async def test_websocket_closed_on_unload( ) as local_client: client = local_client.return_value client.websocket = AsyncMock() - client.websocket.state = State.OPEN + client.websocket.closed = False await async_init_integration(hass, mock_config_entry) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index e8ba727f3db..da86d1bc4de 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -6,7 +6,6 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from demetriek import CloudDevice, Device -from pydantic import parse_raw_as # pylint: disable=no-name-in-module import pytest from homeassistant.components.application_credentials import ( @@ -18,7 +17,7 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_array_fixture @pytest.fixture(autouse=True) @@ -50,8 +49,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.lametric.async_setup_entry", return_value=True - ) as mock_setup: - yield mock_setup + ): + yield @pytest.fixture @@ -61,9 +60,10 @@ def mock_lametric_cloud() -> Generator[MagicMock]: "homeassistant.components.lametric.config_flow.LaMetricCloud", autospec=True ) as lametric_mock: lametric = lametric_mock.return_value - lametric.devices.return_value = parse_raw_as( - list[CloudDevice], load_fixture("cloud_devices.json", DOMAIN) - ) + lametric.devices.return_value = [ + CloudDevice.from_dict(cloud_device) + for cloud_device in load_json_array_fixture("cloud_devices.json", DOMAIN) + ] yield lametric @@ -89,7 +89,7 @@ def mock_lametric(device_fixture: str) -> Generator[MagicMock]: lametric = lametric_mock.return_value lametric.api_key = "mock-api-key" lametric.host = "127.0.0.1" - lametric.device.return_value = Device.parse_raw( + lametric.device.return_value = Device.from_json( load_fixture(f"{device_fixture}.json", DOMAIN) ) yield lametric diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index cadd0e37566..7517cfe035e 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -26,6 +26,10 @@ 'brightness_mode': 'auto', 'display_type': 'mixed', 'height': 8, + 'on': None, + 'screensaver': dict({ + 'enabled': False, + }), 'width': 37, }), 'mode': 'auto', diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 3fbe606c7f1..ccbbe005639 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -41,12 +41,11 @@ SSDP_DISCOVERY_INFO = SsdpServiceInfo( ) -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_cloud_import_flow_multiple_devices( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, ) -> None: @@ -55,25 +54,24 @@ async def test_full_cloud_import_flow_multiple_devices( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) - assert result2.get("type") is FlowResultType.EXTERNAL_STEP - assert result2.get("url") == ( + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( "https://developer.lametric.com/api/v2/oauth2/authorize" "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" @@ -96,37 +94,37 @@ async def test_full_cloud_import_flow_multiple_devices( }, ) - result3 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result3.get("type") is FlowResultType.FORM - assert result3.get("step_id") == "cloud_select_device" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_select_device" - result4 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result4.get("type") is FlowResultType.CREATE_ENTRY - assert result4.get("title") == "Frenck's LaMetric" - assert result4.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result4 - assert result4["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_cloud_import_flow_single_device( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, ) -> None: @@ -135,25 +133,24 @@ async def test_full_cloud_import_flow_single_device( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) - assert result2.get("type") is FlowResultType.EXTERNAL_STEP - assert result2.get("url") == ( + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( "https://developer.lametric.com/api/v2/oauth2/authorize" "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" @@ -181,27 +178,28 @@ async def test_full_cloud_import_flow_single_device( mock_lametric_cloud.devices.return_value = [ mock_lametric_cloud.devices.return_value[0] ] - result3 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_manual( hass: HomeAssistant, - mock_setup_entry: MagicMock, mock_lametric: MagicMock, ) -> None: """Check a full flow manual entry.""" @@ -209,31 +207,34 @@ async def test_full_manual( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "manual_entry" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_entry" - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 @@ -241,15 +242,12 @@ async def test_full_manual( notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] assert notification.model.sound == Sound(sound=NotificationSound.WIN) - assert len(mock_setup_entry.mock_calls) == 1 - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_ssdp_with_cloud_import( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, ) -> None: @@ -258,25 +256,24 @@ async def test_full_ssdp_with_cloud_import( DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY_INFO ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) - assert result2.get("type") is FlowResultType.EXTERNAL_STEP - assert result2.get("url") == ( + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( "https://developer.lametric.com/api/v2/oauth2/authorize" "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" @@ -299,27 +296,27 @@ async def test_full_ssdp_with_cloud_import( }, ) - result3 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_ssdp_manual_entry( hass: HomeAssistant, - mock_setup_entry: MagicMock, mock_lametric: MagicMock, ) -> None: """Check a full flow triggered by SSDP, with manual API key entry.""" @@ -327,35 +324,35 @@ async def test_full_ssdp_manual_entry( DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY_INFO ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "manual_entry" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_entry" - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "mock-api-key"} ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -385,8 +382,8 @@ async def test_ssdp_abort_invalid_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=data ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason @pytest.mark.usefixtures("current_request_with_host") @@ -404,16 +401,15 @@ async def test_cloud_import_updates_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -428,14 +424,14 @@ async def test_cloud_import_updates_existing_entry( "expires_in": 60, }, ) - await hass.config_entries.flow.async_configure(flow_id) + await hass.config_entries.flow.async_configure(result["flow_id"]) - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -458,18 +454,18 @@ async def test_manual_updates_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result3.get("type") is FlowResultType.ABORT - assert result3.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -490,8 +486,8 @@ async def test_discovery_updates_existing_entry( DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY_INFO ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-from-fixture", @@ -510,16 +506,15 @@ async def test_cloud_abort_no_devices( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -537,14 +532,15 @@ async def test_cloud_abort_no_devices( # Stage there are no devices mock_lametric_cloud.devices.return_value = [] - result2 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "no_devices" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices" assert len(mock_lametric_cloud.devices.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("side_effect", "reason"), [ @@ -557,7 +553,6 @@ async def test_cloud_abort_no_devices( async def test_manual_errors( hass: HomeAssistant, mock_lametric: MagicMock, - mock_setup_entry: MagicMock, side_effect: Exception, reason: str, ) -> None: @@ -565,46 +560,47 @@ async def test_manual_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) mock_lametric.device.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "manual_entry" - assert result2.get("errors") == {"base": reason} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_entry" + assert result["errors"] == {"base": reason} assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 mock_lametric.device.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric.device.mock_calls) == 2 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") @pytest.mark.parametrize( ("side_effect", "reason"), [ @@ -618,7 +614,6 @@ async def test_cloud_errors( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, side_effect: Exception, @@ -628,16 +623,15 @@ async def test_cloud_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -652,41 +646,41 @@ async def test_cloud_errors( "expires_in": 60, }, ) - await hass.config_entries.flow.async_configure(flow_id) + await hass.config_entries.flow.async_configure(result["flow_id"]) mock_lametric.device.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "cloud_select_device" - assert result2.get("errors") == {"base": reason} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_select_device" + assert result["errors"] == {"base": reason} assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 mock_lametric.device.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 2 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 async def test_dhcp_discovery_updates_entry( @@ -706,8 +700,8 @@ async def test_dhcp_discovery_updates_entry( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_API_KEY: "mock-from-fixture", CONF_HOST: "127.0.0.42", @@ -732,8 +726,8 @@ async def test_dhcp_unknown_device( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "unknown" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" @pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") @@ -750,16 +744,14 @@ async def test_reauth_cloud_import( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -776,10 +768,10 @@ async def test_reauth_cloud_import( }, ) - result2 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -806,16 +798,14 @@ async def test_reauth_cloud_abort_device_not_found( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -832,10 +822,10 @@ async def test_reauth_cloud_abort_device_not_found( }, ) - result2 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_device_not_found" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_device_not_found" assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 0 @@ -853,18 +843,16 @@ async def test_reauth_manual( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "mock-api-key"} ) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -887,18 +875,16 @@ async def test_reauth_manual_sky( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "mock-api-key"} ) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", diff --git a/tests/components/lametric/test_init.py b/tests/components/lametric/test_init.py index 7352721e992..2fd8219ea51 100644 --- a/tests/components/lametric/test_init.py +++ b/tests/components/lametric/test_init.py @@ -74,7 +74,7 @@ async def test_config_entry_authentication_failed( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "choice_enter_manual_or_fetch_cloud" + assert flow["step_id"] == "choice_enter_manual_or_fetch_cloud" assert flow.get("handler") == DOMAIN assert "context" in flow diff --git a/tests/components/lametric/test_notify.py b/tests/components/lametric/test_notify.py index a46d97f8f81..d30a8c86543 100644 --- a/tests/components/lametric/test_notify.py +++ b/tests/components/lametric/test_notify.py @@ -100,7 +100,7 @@ async def test_notification_options( assert len(notification.model.frames) == 1 frame = notification.model.frames[0] assert type(frame) is Simple - assert frame.icon == 1234 + assert frame.icon == "1234" assert frame.text == "The secret of getting ahead is getting started" diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py index d3fbd0a18e0..b9b5c4c8b3a 100644 --- a/tests/components/lametric/test_services.py +++ b/tests/components/lametric/test_services.py @@ -190,7 +190,7 @@ async def test_service_message( assert len(notification.model.frames) == 1 frame = notification.model.frames[0] assert type(frame) is Simple - assert frame.icon == 6916 + assert frame.icon == "6916" assert frame.text == "Meow!" mock_lametric.notify.side_effect = LaMetricError diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 084ea0c674b..ffe819fa2cb 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN -from homeassistant.components.lifx.const import ATTR_POWER +from homeassistant.components.lifx.const import _ATTR_COLOR_TEMP, ATTR_POWER from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.lifx.manager import ( ATTR_CLOUD_SATURATION_MAX, @@ -31,11 +31,9 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -1264,7 +1262,7 @@ async def test_white_bulb(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 400}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) assert bulb.set_color.calls[0][0][0] == [32000, 0, 32000, 2500] @@ -1719,7 +1717,7 @@ async def test_lifx_set_state_color(hass: HomeAssistant) -> None: async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: - """Test set_state works with old and new kelvin parameter names.""" + """Test set_state works with kelvin parameter names.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL ) @@ -1748,15 +1746,6 @@ async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: assert bulb.set_power.calls[0][0][0] is False bulb.set_power.reset_mock() - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255, ATTR_KELVIN: 3500}, - blocking=True, - ) - assert bulb.set_color.calls[0][0][0] == [32000, 0, 65535, 3500] - bulb.set_color.reset_mock() - await hass.services.async_call( DOMAIN, "set_state", @@ -1769,7 +1758,7 @@ async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255, ATTR_COLOR_TEMP: 400}, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255, _ATTR_COLOR_TEMP: 400}, blocking=True, ) assert bulb.set_color.calls[0][0][0] == [32000, 0, 65535, 2500] diff --git a/tests/components/light/common.py b/tests/components/light/common.py index ba095a03642..b29ac0c7c89 100644 --- a/tests/components/light/common.py +++ b/tests/components/light/common.py @@ -10,11 +10,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_PROFILE, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -22,9 +21,12 @@ from homeassistant.components.light import ( ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, DOMAIN, ColorMode, LightEntity, + LightEntityFeature, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -34,54 +36,10 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.loader import bind_hass from tests.common import MockToggleEntity -@bind_hass -def turn_on( - hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - transition: float | None = None, - brightness: int | None = None, - brightness_pct: float | None = None, - rgb_color: tuple[int, int, int] | None = None, - rgbw_color: tuple[int, int, int, int] | None = None, - rgbww_color: tuple[int, int, int, int, int] | None = None, - xy_color: tuple[float, float] | None = None, - hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, - profile: str | None = None, - flash: str | None = None, - effect: str | None = None, - color_name: str | None = None, - white: bool | None = None, -) -> None: - """Turn all or specified light on.""" - hass.add_job( - async_turn_on, - hass, - entity_id, - transition, - brightness, - brightness_pct, - rgb_color, - rgbw_color, - rgbww_color, - xy_color, - hs_color, - color_temp, - kelvin, - profile, - flash, - effect, - color_name, - white, - ) - - async def async_turn_on( hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL, @@ -93,8 +51,7 @@ async def async_turn_on( rgbww_color: tuple[int, int, int, int, int] | None = None, xy_color: tuple[float, float] | None = None, hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, + color_temp_kelvin: int | None = None, profile: str | None = None, flash: str | None = None, effect: str | None = None, @@ -115,8 +72,7 @@ async def async_turn_on( (ATTR_RGBWW_COLOR, rgbww_color), (ATTR_XY_COLOR, xy_color), (ATTR_HS_COLOR, hs_color), - (ATTR_COLOR_TEMP, color_temp), - (ATTR_KELVIN, kelvin), + (ATTR_COLOR_TEMP_KELVIN, color_temp_kelvin), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), @@ -128,17 +84,6 @@ async def async_turn_on( await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data, blocking=True) -@bind_hass -def turn_off( - hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - transition: float | None = None, - flash: str | None = None, -) -> None: - """Turn all or specified light off.""" - hass.add_job(async_turn_off, hass, entity_id, transition, flash) - - async def async_turn_off( hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL, @@ -159,43 +104,6 @@ async def async_turn_off( await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data, blocking=True) -@bind_hass -def toggle( - hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - transition: float | None = None, - brightness: int | None = None, - brightness_pct: float | None = None, - rgb_color: tuple[int, int, int] | None = None, - xy_color: tuple[float, float] | None = None, - hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, - profile: str | None = None, - flash: str | None = None, - effect: str | None = None, - color_name: str | None = None, -) -> None: - """Toggle all or specified light.""" - hass.add_job( - async_toggle, - hass, - entity_id, - transition, - brightness, - brightness_pct, - rgb_color, - xy_color, - hs_color, - color_temp, - kelvin, - profile, - flash, - effect, - color_name, - ) - - async def async_toggle( hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL, @@ -205,8 +113,7 @@ async def async_toggle( rgb_color: tuple[int, int, int] | None = None, xy_color: tuple[float, float] | None = None, hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, + color_temp_kelvin: int | None = None, profile: str | None = None, flash: str | None = None, effect: str | None = None, @@ -224,8 +131,7 @@ async def async_toggle( (ATTR_RGB_COLOR, rgb_color), (ATTR_XY_COLOR, xy_color), (ATTR_HS_COLOR, hs_color), - (ATTR_COLOR_TEMP, color_temp), - (ATTR_KELVIN, kelvin), + (ATTR_COLOR_TEMP_KELVIN, color_temp_kelvin), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), @@ -249,9 +155,9 @@ TURN_ON_ARG_TO_COLOR_MODE = { class MockLight(MockToggleEntity, LightEntity): """Mock light class.""" - _attr_max_color_temp_kelvin = 6500 - _attr_min_color_temp_kelvin = 2000 - supported_features = 0 + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + supported_features = LightEntityFeature(0) brightness = None color_temp_kelvin = None diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 61e7f4e6c29..303bf68f68c 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1,6 +1,6 @@ """The tests for the Light component.""" -from typing import Literal +from types import ModuleType from unittest.mock import MagicMock, mock_open, patch import pytest @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers import frame from homeassistant.setup import async_setup_component import homeassistant.util.color as color_util @@ -29,6 +30,9 @@ from tests.common import ( MockEntityPlatform, MockUser, async_mock_service, + help_test_all, + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, setup_test_component_platform, ) @@ -133,13 +137,8 @@ async def test_services( ent3.supported_color_modes = [light.ColorMode.HS] ent1.supported_features = light.LightEntityFeature.TRANSITION ent2.supported_features = ( - light.SUPPORT_COLOR - | light.LightEntityFeature.EFFECT - | light.LightEntityFeature.TRANSITION + light.LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION ) - # Set color modes to none to trigger backwards compatibility in LightEntity - ent2.supported_color_modes = None - ent2.color_mode = None ent3.supported_features = ( light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION ) @@ -255,10 +254,7 @@ async def test_services( } _, data = ent2.last_call("turn_on") - assert data == { - light.ATTR_EFFECT: "fun_effect", - light.ATTR_HS_COLOR: (0, 0), - } + assert data == {light.ATTR_EFFECT: "fun_effect"} _, data = ent3.last_call("turn_on") assert data == {light.ATTR_FLASH: "short", light.ATTR_HS_COLOR: (71.059, 100)} @@ -342,8 +338,6 @@ async def test_services( _, data = ent2.last_call("turn_on") assert data == { - light.ATTR_BRIGHTNESS: 100, - light.ATTR_HS_COLOR: profile.hs_color, light.ATTR_TRANSITION: 1, } @@ -921,16 +915,12 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: setup_test_component_platform(hass, light.DOMAIN, entities) entity0 = entities[0] - entity0.supported_features = light.SUPPORT_BRIGHTNESS - # Set color modes to none to trigger backwards compatibility in LightEntity - entity0.supported_color_modes = None - entity0.color_mode = None + entity0.supported_color_modes = {light.ColorMode.BRIGHTNESS} + entity0.color_mode = light.ColorMode.BRIGHTNESS entity0.brightness = 100 entity1 = entities[1] - entity1.supported_features = light.SUPPORT_BRIGHTNESS - # Set color modes to none to trigger backwards compatibility in LightEntity - entity1.supported_color_modes = None - entity1.color_mode = None + entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS} + entity1.color_mode = light.ColorMode.BRIGHTNESS entity1.brightness = 50 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -991,10 +981,8 @@ async def test_light_brightness_pct_conversion( setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) entity = mock_light_entities[0] - 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.supported_color_modes = {light.ColorMode.BRIGHTNESS} + entity.color_mode = light.ColorMode.BRIGHTNESS entity.brightness = 100 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1143,167 +1131,6 @@ invalid_no_brightness_no_color_no_transition,,, assert invalid_profile_name not in profiles.data -@pytest.mark.parametrize("light_state", [STATE_ON, STATE_OFF]) -async def test_light_backwards_compatibility_supported_color_modes( - hass: HomeAssistant, light_state: Literal["on", "off"] -) -> None: - """Test supported_color_modes if not implemented by the entity.""" - entities = [ - MockLight("Test_0", light_state), - MockLight("Test_1", light_state), - MockLight("Test_2", light_state), - MockLight("Test_3", light_state), - MockLight("Test_4", light_state), - ] - - entity0 = entities[0] - - entity1 = entities[1] - entity1.supported_features = light.SUPPORT_BRIGHTNESS - # Set color modes to none to trigger backwards compatibility in LightEntity - entity1.supported_color_modes = None - entity1.color_mode = None - - entity2 = entities[2] - entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP - # Set color modes to none to trigger backwards compatibility in LightEntity - entity2.supported_color_modes = None - entity2.color_mode = None - - entity3 = entities[3] - entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR - # Set color modes to none to trigger backwards compatibility in LightEntity - entity3.supported_color_modes = None - entity3.color_mode = None - - entity4 = entities[4] - entity4.supported_features = ( - light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP - ) - # Set color modes to none to trigger backwards compatibility in LightEntity - entity4.supported_color_modes = None - entity4.color_mode = None - - setup_test_component_platform(hass, light.DOMAIN, entities) - - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) - await hass.async_block_till_done() - - state = hass.states.get(entity0.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] - if light_state == STATE_OFF: - assert state.attributes["color_mode"] is None - else: - assert state.attributes["color_mode"] == light.ColorMode.ONOFF - - state = hass.states.get(entity1.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] - if light_state == STATE_OFF: - assert state.attributes["color_mode"] is None - else: - assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN - - state = hass.states.get(entity2.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] - if light_state == STATE_OFF: - assert state.attributes["color_mode"] is None - else: - assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN - - state = hass.states.get(entity3.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] - if light_state == STATE_OFF: - assert state.attributes["color_mode"] is None - else: - assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN - - state = hass.states.get(entity4.entity_id) - assert state.attributes["supported_color_modes"] == [ - light.ColorMode.COLOR_TEMP, - light.ColorMode.HS, - ] - if light_state == STATE_OFF: - assert state.attributes["color_mode"] is None - else: - assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN - - -async def test_light_backwards_compatibility_color_mode(hass: HomeAssistant) -> None: - """Test color_mode if not implemented by the entity.""" - entities = [ - MockLight("Test_0", STATE_ON), - MockLight("Test_1", STATE_ON), - MockLight("Test_2", STATE_ON), - MockLight("Test_3", STATE_ON), - MockLight("Test_4", STATE_ON), - ] - - entity0 = entities[0] - - entity1 = entities[1] - entity1.supported_features = light.SUPPORT_BRIGHTNESS - # Set color modes to none to trigger backwards compatibility in LightEntity - entity1.supported_color_modes = None - entity1.color_mode = None - entity1.brightness = 100 - - entity2 = entities[2] - entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP - # Set color modes to none to trigger backwards compatibility in LightEntity - entity2.supported_color_modes = None - entity2.color_mode = None - entity2.color_temp_kelvin = 10000 - - entity3 = entities[3] - entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR - # Set color modes to none to trigger backwards compatibility in LightEntity - entity3.supported_color_modes = None - entity3.color_mode = None - entity3.hs_color = (240, 100) - - entity4 = entities[4] - entity4.supported_features = ( - light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP - ) - # Set color modes to none to trigger backwards compatibility in LightEntity - entity4.supported_color_modes = None - entity4.color_mode = None - entity4.hs_color = (240, 100) - entity4.color_temp_kelvin = 10000 - - setup_test_component_platform(hass, light.DOMAIN, entities) - - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) - await hass.async_block_till_done() - - state = hass.states.get(entity0.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] - assert state.attributes["color_mode"] == light.ColorMode.ONOFF - - state = hass.states.get(entity1.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] - assert state.attributes["color_mode"] == light.ColorMode.BRIGHTNESS - - state = hass.states.get(entity2.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] - assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP - assert state.attributes["rgb_color"] == (202, 218, 255) - assert state.attributes["hs_color"] == (221.575, 20.9) - assert state.attributes["xy_color"] == (0.278, 0.287) - - state = hass.states.get(entity3.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] - assert state.attributes["color_mode"] == light.ColorMode.HS - - state = hass.states.get(entity4.entity_id) - assert state.attributes["supported_color_modes"] == [ - light.ColorMode.COLOR_TEMP, - light.ColorMode.HS, - ] - # hs color prioritized over color_temp, light should report mode ColorMode.HS - assert state.attributes["color_mode"] == light.ColorMode.HS - - async def test_light_service_call_rgbw(hass: HomeAssistant) -> None: """Test rgbw functionality in service calls.""" entity0 = MockLight("Test_rgbw", STATE_ON) @@ -1359,7 +1186,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_onoff", "supported_color_modes": [light.ColorMode.ONOFF], - "supported_features": 0, + "supported_features": light.LightEntityFeature(0), } state = hass.states.get(entity1.entity_id) @@ -1367,7 +1194,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_brightness", "supported_color_modes": [light.ColorMode.BRIGHTNESS], - "supported_features": 0, + "supported_features": light.LightEntityFeature(0), "brightness": None, } @@ -1376,14 +1203,14 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_ct", "supported_color_modes": [light.ColorMode.COLOR_TEMP], - "supported_features": 0, + "supported_features": light.LightEntityFeature(0), "brightness": None, "color_temp": None, "color_temp_kelvin": None, "hs_color": None, "rgb_color": None, "xy_color": None, - "max_color_temp_kelvin": 6500, + "max_color_temp_kelvin": 6535, "max_mireds": 500, "min_color_temp_kelvin": 2000, "min_mireds": 153, @@ -1394,7 +1221,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "color_mode": None, "friendly_name": "Test_rgbw", "supported_color_modes": [light.ColorMode.RGBW], - "supported_features": 0, + "supported_features": light.LightEntityFeature(0), "brightness": None, "rgbw_color": None, "hs_color": None, @@ -1425,7 +1252,7 @@ async def test_light_state_rgbw(hass: HomeAssistant) -> None: "color_mode": light.ColorMode.RGBW, "friendly_name": "Test_rgbw", "supported_color_modes": [light.ColorMode.RGBW], - "supported_features": 0, + "supported_features": light.LightEntityFeature(0), "hs_color": (240.0, 25.0), "rgb_color": (3, 3, 4), "rgbw_color": (1, 2, 3, 4), @@ -1456,7 +1283,7 @@ async def test_light_state_rgbww(hass: HomeAssistant) -> None: "color_mode": light.ColorMode.RGBWW, "friendly_name": "Test_rgbww", "supported_color_modes": [light.ColorMode.RGBWW], - "supported_features": 0, + "supported_features": light.LightEntityFeature(0), "hs_color": (60.0, 20.0), "rgb_color": (5, 5, 4), "rgbww_color": (1, 2, 3, 4, 5), @@ -1472,7 +1299,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: MockLight("Test_rgb", STATE_ON), MockLight("Test_xy", STATE_ON), MockLight("Test_all", STATE_ON), - MockLight("Test_legacy", STATE_ON), MockLight("Test_rgbw", STATE_ON), MockLight("Test_rgbww", STATE_ON), MockLight("Test_temperature", STATE_ON), @@ -1496,19 +1322,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: } entity4 = entities[4] - entity4.supported_features = light.SUPPORT_COLOR - # Set color modes to none to trigger backwards compatibility in LightEntity - entity4.supported_color_modes = None - entity4.color_mode = None + entity4.supported_color_modes = {light.ColorMode.RGBW} entity5 = entities[5] - entity5.supported_color_modes = {light.ColorMode.RGBW} + entity5.supported_color_modes = {light.ColorMode.RGBWW} entity6 = entities[6] - entity6.supported_color_modes = {light.ColorMode.RGBWW} - - entity7 = entities[7] - entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP} assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1530,15 +1350,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: ] state = hass.states.get(entity4.entity_id) - assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] - - state = hass.states.get(entity5.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW] - state = hass.states.get(entity6.entity_id) + state = hass.states.get(entity5.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] - state = hass.states.get(entity7.entity_id) + state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] await hass.services.async_call( @@ -1553,7 +1370,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1569,12 +1385,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( @@ -1589,7 +1403,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1605,13 +1418,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (240.0, 0.0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 255, "hs_color": (240.0, 0.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") # The midpoint of the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1626,7 +1437,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1641,13 +1451,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: assert data == {"brightness": 128, "xy_color": (0.701, 0.299)} _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 0, 0)} + _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (0.0, 100.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( @@ -1662,7 +1471,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1678,13 +1486,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 255, 255)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (0.0, 0.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1699,7 +1505,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1715,12 +1520,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "xy_color": (0.1, 0.8)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (125.176, 100.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( @@ -1735,7 +1538,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1751,13 +1553,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "xy_color": (0.323, 0.329)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (0.0, 0.392)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1772,7 +1572,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1788,13 +1587,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 43, 43)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (0.0, 66.406)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( @@ -1809,7 +1606,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1825,13 +1621,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 255, 255)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (0.0, 0.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( @@ -1846,7 +1640,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1862,12 +1655,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (128, 33, 26)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (4.118, 79.688)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( @@ -1882,7 +1673,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity4.entity_id, entity5.entity_id, entity6.entity_id, - entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1898,13 +1688,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: _, data = entity3.last_call("turn_on") assert data == {"brightness": 128, "rgb_color": (255, 217, 185)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 128, "hs_color": (27.429, 27.451)} - _, data = entity5.last_call("turn_on") # The midpoint the white channels is warm, compensated by decreasing green + blue assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} - _, data = entity7.last_call("turn_on") + _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} @@ -1917,7 +1705,6 @@ async def test_light_service_call_color_conversion_named_tuple( MockLight("Test_rgb", STATE_ON), MockLight("Test_xy", STATE_ON), MockLight("Test_all", STATE_ON), - MockLight("Test_legacy", STATE_ON), MockLight("Test_rgbw", STATE_ON), MockLight("Test_rgbww", STATE_ON), ] @@ -1940,16 +1727,10 @@ async def test_light_service_call_color_conversion_named_tuple( } entity4 = entities[4] - entity4.supported_features = light.SUPPORT_COLOR - # Set color modes to none to trigger backwards compatibility in LightEntity - entity4.supported_color_modes = None - entity4.color_mode = None + entity4.supported_color_modes = {light.ColorMode.RGBW} entity5 = entities[5] - entity5.supported_color_modes = {light.ColorMode.RGBW} - - entity6 = entities[6] - entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity5.supported_color_modes = {light.ColorMode.RGBWW} assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1965,7 +1746,6 @@ async def test_light_service_call_color_conversion_named_tuple( entity3.entity_id, entity4.entity_id, entity5.entity_id, - entity6.entity_id, ], "brightness_pct": 25, "rgb_color": color_util.RGBColor(128, 0, 0), @@ -1981,10 +1761,8 @@ async def test_light_service_call_color_conversion_named_tuple( _, data = entity3.last_call("turn_on") assert data == {"brightness": 64, "rgb_color": (128, 0, 0)} _, data = entity4.last_call("turn_on") - assert data == {"brightness": 64, "hs_color": (0.0, 100.0)} - _, data = entity5.last_call("turn_on") assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)} - _, data = entity6.last_call("turn_on") + _, data = entity5.last_call("turn_on") assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)} @@ -2065,7 +1843,7 @@ async def test_light_service_call_color_temp_conversion(hass: HomeAssistant) -> assert entity1.min_mireds == 153 assert entity1.max_mireds == 500 assert entity1.min_color_temp_kelvin == 2000 - assert entity1.max_color_temp_kelvin == 6500 + assert entity1.max_color_temp_kelvin == 6535 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -2078,7 +1856,7 @@ async def test_light_service_call_color_temp_conversion(hass: HomeAssistant) -> assert state.attributes["min_mireds"] == 153 assert state.attributes["max_mireds"] == 500 assert state.attributes["min_color_temp_kelvin"] == 2000 - assert state.attributes["max_color_temp_kelvin"] == 6500 + assert state.attributes["max_color_temp_kelvin"] == 6535 state = hass.states.get(entity1.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] @@ -2353,13 +2131,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: entity2.rgb_color = "Invalid" # Should be ignored entity2.xy_color = (0.1, 0.8) - entity3 = entities[3] - entity3.hs_color = (240, 100) - entity3.supported_features = light.SUPPORT_COLOR - # Set color modes to none to trigger backwards compatibility in LightEntity - entity3.supported_color_modes = None - entity3.color_mode = None - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -2381,12 +2152,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: assert state.attributes["rgb_color"] == (0, 255, 22) assert state.attributes["xy_color"] == (0.1, 0.8) - state = hass.states.get(entity3.entity_id) - assert state.attributes["color_mode"] == light.ColorMode.HS - assert state.attributes["hs_color"] == (240, 100) - assert state.attributes["rgb_color"] == (0, 0, 255) - assert state.attributes["xy_color"] == (0.136, 0.04) - async def test_services_filter_parameters( hass: HomeAssistant, @@ -2621,27 +2386,6 @@ def test_filter_supported_color_modes() -> None: assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockLightEntityEntity(light.LightEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockLightEntityEntity() - assert entity.supported_features_compat is light.LightEntityFeature(1) - assert "MockLightEntityEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "LightEntityFeature" in caplog.text - assert "and color modes" in caplog.text - caplog.clear() - assert entity.supported_features_compat is light.LightEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - @pytest.mark.parametrize( ("color_mode", "supported_color_modes", "warning_expected"), [ @@ -2802,3 +2546,195 @@ def test_report_invalid_color_modes( entity._async_calculate_state() expected_warning = "sets invalid supported color modes" assert (expected_warning in caplog.text) is warning_expected + + +@pytest.mark.parametrize( + ("attributes", "expected_warnings", "expected_values"), + [ + ( + { + "_attr_color_temp_kelvin": 4000, + "_attr_min_color_temp_kelvin": 3000, + "_attr_max_color_temp_kelvin": 5000, + }, + {"current": False, "warmest": False, "coldest": False}, + # Just highlighting that the attributes match the + # converted kelvin values, not the mired properties + (3000, 4000, 5000, 200, 250, 333, 153, None, 500), + ), + ( + {"_attr_color_temp": 350, "_attr_min_mireds": 300, "_attr_max_mireds": 400}, + {"current": True, "warmest": True, "coldest": True}, + (2500, 2857, 3333, 300, 350, 400, 300, 350, 400), + ), + ( + {}, + {"current": False, "warmest": True, "coldest": True}, + (2000, None, 6535, 153, None, 500, 153, None, 500), + ), + ], + ids=["with_kelvin", "with_mired_values", "with_mired_defaults"], +) +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +def test_missing_kelvin_property_warnings( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + attributes: dict[str, int | None], + expected_warnings: dict[str, bool], + expected_values: tuple[int, int | None, int], +) -> None: + """Test missing kelvin properties.""" + + class MockLightEntityEntity(light.LightEntity): + _attr_color_mode = light.ColorMode.COLOR_TEMP + _attr_is_on = True + _attr_supported_features = light.LightEntityFeature.EFFECT + _attr_supported_color_modes = {light.ColorMode.COLOR_TEMP} + platform = MockEntityPlatform(hass, platform_name="test") + + entity = MockLightEntityEntity() + for k, v in attributes.items(): + setattr(entity, k, v) + + state = entity._async_calculate_state() + for warning, expected in expected_warnings.items(): + assert ( + f"is using mireds for {warning} light color temperature" in caplog.text + ) is expected, f"Expected {expected} for '{warning}'" + + assert state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN] == expected_values[0] + assert state.attributes[light.ATTR_COLOR_TEMP_KELVIN] == expected_values[1] + assert state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN] == expected_values[2] + assert state.attributes[light.ATTR_MIN_MIREDS] == expected_values[3] + assert state.attributes[light.ATTR_COLOR_TEMP] == expected_values[4] + assert state.attributes[light.ATTR_MAX_MIREDS] == expected_values[5] + assert entity.min_mireds == expected_values[6] + assert entity.color_temp == expected_values[7] + assert entity.max_mireds == expected_values[8] + + +@pytest.mark.parametrize( + "module", + [light], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + +@pytest.mark.parametrize( + ("constant_name", "constant_value", "constant_replacement"), + [ + ("SUPPORT_BRIGHTNESS", 1, "supported_color_modes"), + ("SUPPORT_COLOR_TEMP", 2, "supported_color_modes"), + ("SUPPORT_COLOR", 16, "supported_color_modes"), + ("ATTR_COLOR_TEMP", "color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)"), + ("ATTR_KELVIN", "kelvin", "ATTR_COLOR_TEMP_KELVIN"), + ( + "ATTR_MIN_MIREDS", + "min_mireds", + "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", + ), + ( + "ATTR_MAX_MIREDS", + "max_mireds", + "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", + ), + ], +) +def test_deprecated_light_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + constant_value: int | str, + constant_replacement: str, +) -> None: + """Test deprecated light constants.""" + import_and_test_deprecated_constant( + caplog, light, constant_name, constant_replacement, constant_value, "2026.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(light.LightEntityFeature), +) +def test_deprecated_support_light_constants_enums( + caplog: pytest.LogCaptureFixture, + entity_feature: light.LightEntityFeature, +) -> None: + """Test deprecated support light constants.""" + import_and_test_deprecated_constant_enum( + caplog, light, entity_feature, "SUPPORT_", "2026.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(light.ColorMode), +) +def test_deprecated_color_mode_constants_enums( + caplog: pytest.LogCaptureFixture, + entity_feature: light.LightEntityFeature, +) -> None: + """Test deprecated support light constants.""" + import_and_test_deprecated_constant_enum( + caplog, light, entity_feature, "COLOR_MODE_", "2026.1" + ) + + +async def test_deprecated_turn_on_arguments( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test color temp conversion in service calls.""" + entity = MockLight("Test_ct", STATE_ON, {light.ColorMode.COLOR_TEMP}) + setup_test_component_platform(hass, light.DOMAIN, [entity]) + + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + + caplog.clear() + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "color_temp": 200, + }, + blocking=True, + ) + assert "Got `color_temp` argument in `turn_on` service" in caplog.text + _, data = entity.last_call("turn_on") + assert data == {"color_temp": 200, "color_temp_kelvin": 5000} + + caplog.clear() + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "kelvin": 5000, + }, + blocking=True, + ) + assert "Got `kelvin` argument in `turn_on` service" in caplog.text + _, data = entity.last_call("turn_on") + assert data == {"color_temp": 200, "color_temp_kelvin": 5000} + + caplog.clear() + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "color_temp_kelvin": 5000, + }, + blocking=True, + ) + _, data = entity.last_call("turn_on") + assert data == {"color_temp": 200, "color_temp_kelvin": 5000} + assert "argument in `turn_on` service" not in caplog.text diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index f3f87ff6074..d53ece61170 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -9,17 +9,17 @@ import pytest from homeassistant.components import light from homeassistant.components.light import ( + _DEPRECATED_ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_MAX_MIREDS, + _DEPRECATED_ATTR_MIN_MIREDS, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -66,8 +66,8 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: - assert ATTR_MIN_MIREDS not in state.attributes - assert ATTR_MAX_MIREDS not in state.attributes + assert _DEPRECATED_ATTR_MIN_MIREDS.value not in state.attributes + assert _DEPRECATED_ATTR_MAX_MIREDS.value not in state.attributes assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes assert ATTR_EFFECT_LIST not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes @@ -75,7 +75,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes assert ATTR_BRIGHTNESS not in state.attributes assert ATTR_COLOR_MODE not in state.attributes - assert ATTR_COLOR_TEMP not in state.attributes + assert _DEPRECATED_ATTR_COLOR_TEMP.value not in state.attributes assert ATTR_COLOR_TEMP_KELVIN not in state.attributes assert ATTR_EFFECT not in state.attributes assert ATTR_HS_COLOR not in state.attributes diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index aa698129915..987e97c6eb2 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -10,7 +10,7 @@ from tests.common import async_mock_service VALID_BRIGHTNESS = {"brightness": 180} VALID_EFFECT = {"effect": "random"} -VALID_COLOR_TEMP = {"color_temp": 240} +VALID_COLOR_TEMP_KELVIN = {"color_temp_kelvin": 4200} VALID_HS_COLOR = {"hs_color": (345, 75)} VALID_RGB_COLOR = {"rgb_color": (255, 63, 111)} VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} @@ -19,7 +19,7 @@ VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} NONE_BRIGHTNESS = {"brightness": None} NONE_EFFECT = {"effect": None} -NONE_COLOR_TEMP = {"color_temp": None} +NONE_COLOR_TEMP_KELVIN = {"color_temp_kelvin": None} NONE_HS_COLOR = {"hs_color": None} NONE_RGB_COLOR = {"rgb_color": None} NONE_RGBW_COLOR = {"rgbw_color": None} @@ -34,7 +34,7 @@ async def test_reproducing_states( hass.states.async_set("light.entity_off", "off", {}) hass.states.async_set("light.entity_bright", "on", VALID_BRIGHTNESS) hass.states.async_set("light.entity_effect", "on", VALID_EFFECT) - hass.states.async_set("light.entity_temp", "on", VALID_COLOR_TEMP) + hass.states.async_set("light.entity_temp", "on", VALID_COLOR_TEMP_KELVIN) hass.states.async_set("light.entity_hs", "on", VALID_HS_COLOR) hass.states.async_set("light.entity_rgb", "on", VALID_RGB_COLOR) hass.states.async_set("light.entity_xy", "on", VALID_XY_COLOR) @@ -49,7 +49,7 @@ async def test_reproducing_states( State("light.entity_off", "off"), State("light.entity_bright", "on", VALID_BRIGHTNESS), State("light.entity_effect", "on", VALID_EFFECT), - State("light.entity_temp", "on", VALID_COLOR_TEMP), + State("light.entity_temp", "on", VALID_COLOR_TEMP_KELVIN), State("light.entity_hs", "on", VALID_HS_COLOR), State("light.entity_rgb", "on", VALID_RGB_COLOR), State("light.entity_xy", "on", VALID_XY_COLOR), @@ -73,7 +73,7 @@ async def test_reproducing_states( State("light.entity_xy", "off"), State("light.entity_off", "on", VALID_BRIGHTNESS), State("light.entity_bright", "on", VALID_EFFECT), - State("light.entity_effect", "on", VALID_COLOR_TEMP), + State("light.entity_effect", "on", VALID_COLOR_TEMP_KELVIN), State("light.entity_temp", "on", VALID_HS_COLOR), State("light.entity_hs", "on", VALID_RGB_COLOR), State("light.entity_rgb", "on", VALID_XY_COLOR), @@ -92,7 +92,7 @@ async def test_reproducing_states( expected_bright["entity_id"] = "light.entity_bright" expected_calls.append(expected_bright) - expected_effect = dict(VALID_COLOR_TEMP) + expected_effect = dict(VALID_COLOR_TEMP_KELVIN) expected_effect["entity_id"] = "light.entity_effect" expected_calls.append(expected_effect) @@ -146,7 +146,7 @@ async def test_filter_color_modes( """Test filtering of parameters according to color mode.""" hass.states.async_set("light.entity", "off", {}) all_colors = { - **VALID_COLOR_TEMP, + **VALID_COLOR_TEMP_KELVIN, **VALID_HS_COLOR, **VALID_RGB_COLOR, **VALID_RGBW_COLOR, @@ -162,7 +162,7 @@ async def test_filter_color_modes( ) expected_map = { - light.ColorMode.COLOR_TEMP: {**VALID_BRIGHTNESS, **VALID_COLOR_TEMP}, + light.ColorMode.COLOR_TEMP: {**VALID_BRIGHTNESS, **VALID_COLOR_TEMP_KELVIN}, light.ColorMode.BRIGHTNESS: VALID_BRIGHTNESS, light.ColorMode.HS: {**VALID_BRIGHTNESS, **VALID_HS_COLOR}, light.ColorMode.ONOFF: {**VALID_BRIGHTNESS}, @@ -193,12 +193,76 @@ async def test_filter_color_modes( assert len(turn_on_calls) == 1 +async def test_filter_color_modes_missing_attributes( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test warning on missing attribute when filtering for color mode.""" + color_mode = light.ColorMode.COLOR_TEMP + hass.states.async_set("light.entity", "off", {}) + expected_log = ( + "Color mode color_temp specified " + "but attribute color_temp_kelvin missing for: light.entity" + ) + expected_fallback_log = "using color_temp (mireds) as fallback" + + turn_on_calls = async_mock_service(hass, "light", "turn_on") + + all_colors = { + **VALID_COLOR_TEMP_KELVIN, + **VALID_HS_COLOR, + **VALID_RGB_COLOR, + **VALID_RGBW_COLOR, + **VALID_RGBWW_COLOR, + **VALID_XY_COLOR, + **VALID_BRIGHTNESS, + } + + # Test missing `color_temp_kelvin` attribute + stored_attributes = {**all_colors} + stored_attributes.pop("color_temp_kelvin") + caplog.clear() + await async_reproduce_state( + hass, + [State("light.entity", "on", {**stored_attributes, "color_mode": color_mode})], + ) + assert len(turn_on_calls) == 0 + assert expected_log in caplog.text + assert expected_fallback_log not in caplog.text + + # Test with deprecated `color_temp` attribute + stored_attributes["color_temp"] = 250 + expected = {"brightness": 180, "color_temp_kelvin": 4000} + caplog.clear() + await async_reproduce_state( + hass, + [State("light.entity", "on", {**stored_attributes, "color_mode": color_mode})], + ) + + assert len(turn_on_calls) == 1 + assert expected_log in caplog.text + assert expected_fallback_log in caplog.text + + # Test with correct `color_temp_kelvin` attribute + expected = {"brightness": 180, "color_temp_kelvin": 4200} + caplog.clear() + turn_on_calls.clear() + await async_reproduce_state( + hass, + [State("light.entity", "on", {**all_colors, "color_mode": color_mode})], + ) + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "light" + assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity", **expected} + assert expected_log not in caplog.text + assert expected_fallback_log not in caplog.text + + @pytest.mark.parametrize( "saved_state", [ NONE_BRIGHTNESS, NONE_EFFECT, - NONE_COLOR_TEMP, + NONE_COLOR_TEMP_KELVIN, NONE_HS_COLOR, NONE_RGB_COLOR, NONE_RGBW_COLOR, diff --git a/tests/components/light/test_significant_change.py b/tests/components/light/test_significant_change.py index 87a60b58325..cf03f37228e 100644 --- a/tests/components/light/test_significant_change.py +++ b/tests/components/light/test_significant_change.py @@ -2,7 +2,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ) @@ -26,10 +26,10 @@ async def test_significant_change() -> None: # Color temp assert not async_check_significant_change( - None, "on", {ATTR_COLOR_TEMP: 60}, "on", {ATTR_COLOR_TEMP: 64} + None, "on", {ATTR_COLOR_TEMP_KELVIN: 2000}, "on", {ATTR_COLOR_TEMP_KELVIN: 2049} ) assert async_check_significant_change( - None, "on", {ATTR_COLOR_TEMP: 60}, "on", {ATTR_COLOR_TEMP: 65} + None, "on", {ATTR_COLOR_TEMP_KELVIN: 2000}, "on", {ATTR_COLOR_TEMP_KELVIN: 2050} ) # Effect diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index c689d04949a..db82f41eb73 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -73,6 +73,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'test-site-name', 'unique_id': None, 'version': 1, diff --git a/tests/components/linkplay/test_diagnostics.py b/tests/components/linkplay/test_diagnostics.py index 369142978a3..de60b7ecb3a 100644 --- a/tests/components/linkplay/test_diagnostics.py +++ b/tests/components/linkplay/test_diagnostics.py @@ -31,8 +31,10 @@ async def test_diagnostics( patch.object(LinkPlayMultiroom, "update_status", return_value=None), ): endpoints = [ - LinkPlayApiEndpoint(protocol="https", endpoint=HOST, session=None), - LinkPlayApiEndpoint(protocol="http", endpoint=HOST, session=None), + LinkPlayApiEndpoint( + protocol="https", port=443, endpoint=HOST, session=None + ), + LinkPlayApiEndpoint(protocol="http", port=80, endpoint=HOST, session=None), ] for endpoint in endpoints: mock_session.get( diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 21b16097603..1c8e0742b26 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import litterrobot from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_START, - STATE_DOCKED, + VacuumActivity, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID @@ -30,7 +30,7 @@ async def test_unload_entry(hass: HomeAssistant, mock_account: MagicMock) -> Non vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_DOCKED + assert vacuum.state == VacuumActivity.DOCKED await hass.services.async_call( VACUUM_DOMAIN, diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 735ee6653aa..f18098ccf1d 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -15,9 +15,7 @@ from homeassistant.components.vacuum import ( DOMAIN as PLATFORM_DOMAIN, SERVICE_START, SERVICE_STOP, - STATE_DOCKED, - STATE_ERROR, - STATE_PAUSED, + VacuumActivity, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -53,7 +51,7 @@ async def test_vacuum( vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_DOCKED + assert vacuum.state == VacuumActivity.DOCKED assert vacuum.attributes["is_sleeping"] is False ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) @@ -95,18 +93,21 @@ async def test_vacuum_with_error( vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_ERROR + assert vacuum.state == VacuumActivity.ERROR @pytest.mark.parametrize( ("robot_data", "expected_state"), [ - ({"displayCode": "DC_CAT_DETECT"}, STATE_DOCKED), - ({"isDFIFull": True}, STATE_ERROR), - ({"robotCycleState": "CYCLE_STATE_CAT_DETECT"}, STATE_PAUSED), + ({"displayCode": "DC_CAT_DETECT"}, VacuumActivity.DOCKED), + ({"isDFIFull": True}, VacuumActivity.ERROR), + ( + {"robotCycleState": "CYCLE_STATE_CAT_DETECT"}, + VacuumActivity.PAUSED, + ), ], ) -async def test_vacuum_states( +async def test_activities( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock, robot_data: dict[str, str | bool], @@ -150,7 +151,7 @@ async def test_commands( vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_DOCKED + assert vacuum.state == VacuumActivity.DOCKED extra = extra or {} data = {ATTR_ENTITY_ID: VACUUM_ENTITY_ID, **extra.get("data", {})} diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index a1fed9fe7e1..68af8c7d482 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -417,20 +417,3 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, lock, enum, constant_prefix, remove_in_version ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockLockEntity(lock.LockEntity): - _attr_supported_features = 1 - - entity = MockLockEntity() - assert entity.supported_features is lock.LockEntityFeature(1) - assert "MockLockEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "LockEntityFeature.OPEN" in caplog.text - caplog.clear() - assert entity.supported_features is lock.LockEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr index 3a281391860..92d0578dba8 100644 --- a/tests/components/madvr/snapshots/test_diagnostics.ambr +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'envy', 'unique_id': '00:11:22:33:44:55', 'version': 1, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 000b0d4e2e6..8cc9d068caf 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -647,7 +647,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 95447783bbc..28c93de5e11 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -460,7 +460,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 1d1d450e1f0..5600a7e801b 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -444,7 +444,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/multi_endpoint_light.json b/tests/components/matter/fixtures/nodes/multi_endpoint_light.json index e3a01da9e7c..3b9be24d9ab 100644 --- a/tests/components/matter/fixtures/nodes/multi_endpoint_light.json +++ b/tests/components/matter/fixtures/nodes/multi_endpoint_light.json @@ -1620,7 +1620,7 @@ "6/768/16385": 0, "6/768/16394": 25, "6/768/16395": 0, - "6/768/16396": 65279, + "6/768/16396": 0, "6/768/16397": 0, "6/768/16400": 0, "6/768/65532": 25, diff --git a/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json b/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json index 46575640adf..ac462cd7951 100644 --- a/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json +++ b/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json @@ -384,7 +384,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/onoff_light_no_name.json b/tests/components/matter/fixtures/nodes/onoff_light_no_name.json index a6c73564af0..19cd58bf5cb 100644 --- a/tests/components/matter/fixtures/nodes/onoff_light_no_name.json +++ b/tests/components/matter/fixtures/nodes/onoff_light_no_name.json @@ -384,7 +384,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 96346b906c3..60a3d33a130 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1145,6 +1145,98 @@ 'state': '189.0', }) # --- +# name: test_sensors[door_lock][sensor.mock_door_lock_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_door_lock_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[door_lock][sensor.mock_door_lock_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Battery type', + }), + 'context': , + 'entity_id': 'sensor.mock_door_lock_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_door_lock_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Door Lock Battery type', + }), + 'context': , + 'entity_id': 'sensor.mock_door_lock_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1196,6 +1288,52 @@ 'state': '100', }) # --- +# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_door_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Door Battery type', + }), + 'context': , + 'entity_id': 'sensor.eve_door_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1543,6 +1681,9 @@ 'sensor': dict({ 'suggested_display_precision': 3, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1730,6 +1871,52 @@ 'state': '100', }) # --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_thermo_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Battery type', + }), + 'context': , + 'entity_id': 'sensor.eve_thermo_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- # name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1879,6 +2066,52 @@ 'state': '100', }) # --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_weather_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Weather Battery type', + }), + 'context': , + 'entity_id': 'sensor.eve_weather_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2480,6 +2713,9 @@ 'sensor': dict({ 'suggested_display_precision': 3, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2729,6 +2965,52 @@ 'state': '94', }) # --- +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_sensor_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke sensor Battery type', + }), + 'context': , + 'entity_id': 'sensor.smoke_sensor_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CR123A', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 27eb7da2c71..3215ec58116 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -174,6 +174,26 @@ async def test_battery_sensor_voltage( assert entry.entity_category == EntityCategory.DIAGNOSTIC +@pytest.mark.parametrize("node_fixture", ["smoke_detector"]) +async def test_battery_sensor_description( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test battery replacement description sensor.""" + state = hass.states.get("sensor.smoke_sensor_battery_type") + assert state + assert state.state == "CR123A" + + set_node_attribute(matter_node, 1, 47, 19, "CR2032") + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.smoke_sensor_battery_type") + assert state + assert state.state == "CR2032" + + @pytest.mark.parametrize("node_fixture", ["eve_thermo"]) async def test_eve_thermo_sensor( hass: HomeAssistant, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 47f0530f0ff..7c64f846df1 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -97,6 +97,46 @@ def test_deprecated_constants_const( ) +@pytest.mark.parametrize( + "property_suffix", + [ + "play", + "pause", + "stop", + "seek", + "volume_set", + "volume_mute", + "previous_track", + "next_track", + "play_media", + "select_source", + "select_sound_mode", + "clear_playlist", + "shuffle_set", + "grouping", + ], +) +def test_support_properties(property_suffix: str) -> None: + """Test support_*** properties explicitly.""" + + all_features = media_player.MediaPlayerEntityFeature(653887) + feature = media_player.MediaPlayerEntityFeature[property_suffix.upper()] + + entity1 = MediaPlayerEntity() + entity1._attr_supported_features = media_player.MediaPlayerEntityFeature(0) + entity2 = MediaPlayerEntity() + entity2._attr_supported_features = all_features + entity3 = MediaPlayerEntity() + entity3._attr_supported_features = feature + entity4 = MediaPlayerEntity() + entity4._attr_supported_features = all_features & ~feature + + assert getattr(entity1, f"support_{property_suffix}") is False + assert getattr(entity2, f"support_{property_suffix}") is True + assert getattr(entity3, f"support_{property_suffix}") is True + assert getattr(entity4, f"support_{property_suffix}") is False + + async def test_get_image_http( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: @@ -407,23 +447,3 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockMediaPlayerEntity(MediaPlayerEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockMediaPlayerEntity() - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "MockMediaPlayerEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "MediaPlayerEntityFeature.PAUSE" in caplog.text - caplog.clear() - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr index e6a432de07e..671f5afcc52 100644 --- a/tests/components/melcloud/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'melcloud', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 3b8a76f5606..0cfa7ba8b24 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -19,7 +19,7 @@ from unittest import mock from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse, IllegalFunctionRequest +from pymodbus.pdu import ExceptionResponse import pytest import voluptuous as vol @@ -820,7 +820,6 @@ SERVICE = "service" [ {VALUE: ReadResult([0x0001]), DATA: ""}, {VALUE: ExceptionResponse(0x06), DATA: "Pymodbus:"}, - {VALUE: IllegalFunctionRequest(0x06), DATA: "Pymodbus:"}, {VALUE: ModbusException("fail write_"), DATA: "Pymodbus:"}, ], ) @@ -928,7 +927,6 @@ async def mock_modbus_read_pymodbus_fixture( ("do_return", "do_exception", "do_expect_state", "do_expect_value"), [ (ReadResult([1]), None, STATE_ON, "1"), - (IllegalFunctionRequest(0x99), None, STATE_UNAVAILABLE, STATE_UNAVAILABLE), (ExceptionResponse(0x99), None, STATE_UNAVAILABLE, STATE_UNAVAILABLE), ( ReadResult([1]), diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index f8897a4a47f..1b4090ca5a4 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'AA:BB:CC:DD:EE:FF', 'version': 1, diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py index 9df0e18d9ed..bb8362b5e0d 100644 --- a/tests/components/mold_indicator/test_config_flow.py +++ b/tests/components/mold_indicator/test_config_flow.py @@ -70,6 +70,9 @@ async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", CONF_CALIBRATION_FACTOR: 3.0, }, ) diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr index 5b4b169c0fe..d042dc02ac3 100644 --- a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -28,6 +28,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 164c164cdfc..4bfcde752ae 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -1045,10 +1045,17 @@ async def test_restore_subscriptions_on_reconnect( mqtt_client_mock.reset_mock() mqtt_client_mock.on_disconnect(None, None, 0) + # Test to subscribe orther topic while the client is not connected + await mqtt.async_subscribe(hass, "test/other", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) + mock_debouncer.clear() mqtt_client_mock.on_connect(None, None, None, 0) await mock_debouncer.wait() + # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) + assert ("test/other", 0) in help_all_subscribe_calls(mqtt_client_mock) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e99063b088b..38dbda50cdd 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2162,7 +2162,7 @@ async def test_setup_with_advanced_settings( async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: - """Test option flow setup with websockets transport settings.""" + """Test reconfiguration flow changing websockets transport settings.""" config_entry = MockConfigEntry(domain=mqtt.DOMAIN) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( @@ -2178,7 +2178,7 @@ async def test_change_websockets_transport_to_tcp( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["transport"] @@ -2186,7 +2186,7 @@ async def test_change_websockets_transport_to_tcp( assert result["data_schema"].schema["ws_headers"] # Change transport to tcp - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2196,23 +2196,61 @@ async def test_change_websockets_transport_to_tcp( mqtt.CONF_WS_PATH: "/some_path", }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "options" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" # Check config entry result assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, mqtt.CONF_TRANSPORT: "tcp", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", } + + +@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_path", + } + ], +) +async def test_reconfigure_flow_form( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reconfigure flow.""" + await mqtt_mock_entry() + entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "10.10.10,10", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}', + mqtt.CONF_WS_PATH: "/some_new_path", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + mqtt.CONF_BROKER: "10.10.10,10", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_new_path", + } + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 009a0315029..5cdfb14a5cf 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -2,6 +2,7 @@ import json from typing import Any +from unittest.mock import patch import pytest from pytest_unordered import unordered @@ -1692,14 +1693,19 @@ async def test_trigger_debug_info( assert debug_info_data["triggers"][0]["discovery_data"]["payload"] == config2 -@pytest.mark.usefixtures("mqtt_mock") +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) async def test_unload_entry( hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, service_calls: list[ServiceCall], device_registry: dr.DeviceRegistry, ) -> None: """Test unloading the MQTT entry.""" + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -1733,6 +1739,7 @@ async def test_unload_entry( ] }, ) + await hass.async_block_till_done() # Fake short press 1 async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index b11484d55fb..dbca09e803c 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -270,7 +270,7 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None assert state.attributes.get("rgbw_color") is None @@ -285,7 +285,7 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None assert state.attributes.get("rgbw_color") is None @@ -350,7 +350,7 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -366,7 +366,7 @@ async def test_controlling_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -649,7 +649,7 @@ async def test_invalid_state_via_topic( assert state.attributes.get("rgbw_color") is None assert state.attributes.get("rgbww_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("xy_color") is None @@ -665,7 +665,7 @@ async def test_invalid_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") == "none" assert state.attributes.get("hs_color") == (0, 0) assert state.attributes.get("xy_color") == (0.323, 0.329) @@ -723,14 +723,14 @@ async def test_invalid_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 251) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") == 153 + assert state.attributes.get("color_temp_kelvin") == 6535 assert state.attributes.get("effect") == "none" assert state.attributes.get("hs_color") == (54.768, 1.6) assert state.attributes.get("xy_color") == (0.325, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") light_state = hass.states.get("light.test") - assert light_state.attributes["color_temp"] == 153 + assert light_state.attributes["color_temp_kelvin"] == 6535 @pytest.mark.parametrize( @@ -939,7 +939,7 @@ async def test_controlling_state_via_topic_with_templates( hass, "test_light_rgb/color_temp/status", '{"hello": "300"}' ) state = hass.states.get("light.test") - assert state.attributes.get("color_temp") == 300 + assert state.attributes.get("color_temp_kelvin") == 3333 assert state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -1008,7 +1008,7 @@ async def test_sending_mqtt_commands_and_optimistic( "brightness": 95, "hs_color": [100, 100], "effect": "random", - "color_temp": 100, + "color_temp_kelvin": 100000, "color_mode": "hs", }, ) @@ -1021,7 +1021,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("brightness") == 95 assert state.attributes.get("hs_color") == (100, 100) assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get(light.ATTR_COLOR_MODE) == "hs" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -1148,7 +1148,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(light.ATTR_COLOR_MODE) == "xy" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - await common.async_turn_on(hass, "light.test", color_temp=125) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=8000) mqtt_mock.async_publish.assert_has_calls( [ call("test_light_rgb/color_temp/set", "125", 2, False), @@ -1160,7 +1160,7 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 60 - assert state.attributes.get("color_temp") == 125 + assert state.attributes.get("color_temp_kelvin") == 8000 assert state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -1321,7 +1321,7 @@ async def test_sending_mqtt_color_temp_command_with_template( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - await common.async_turn_on(hass, "light.test", color_temp=100) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=10000) mqtt_mock.async_publish.assert_has_calls( [ @@ -2103,7 +2103,7 @@ async def test_explicit_color_mode( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -2119,7 +2119,7 @@ async def test_explicit_color_mode( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -2248,7 +2248,7 @@ async def test_explicit_color_mode_templated( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get(light.ATTR_COLOR_MODE) is None assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -2258,7 +2258,7 @@ async def test_explicit_color_mode_templated( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get(light.ATTR_COLOR_MODE) == "unknown" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f0da483e706..988cce85653 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -423,7 +423,9 @@ async def test_single_color_mode( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + await common.async_turn_on( + hass, "light.test", brightness=50, color_temp_kelvin=5208 + ) async_fire_mqtt_message( hass, @@ -435,7 +437,7 @@ async def test_single_color_mode( assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -454,15 +456,17 @@ async def test_turn_on_with_unknown_color_mode_optimistic( state = hass.states.get("light.test") assert state.attributes.get("color_mode") == light.ColorMode.UNKNOWN assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.state == STATE_ON # Turn on the light with brightness or color_temp attributes - await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + await common.async_turn_on( + hass, "light.test", brightness=50, color_temp_kelvin=5208 + ) state = hass.states.get("light.test") assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP assert state.attributes.get("brightness") == 50 - assert state.attributes.get("color_temp") == 192 + assert state.attributes.get("color_temp_kelvin") == 5208 assert state.state == STATE_ON @@ -494,7 +498,7 @@ async def test_controlling_state_with_unknown_color_mode( ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get(light.ATTR_COLOR_TEMP) is None + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(light.ATTR_BRIGHTNESS) is None assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.UNKNOWN @@ -507,7 +511,7 @@ async def test_controlling_state_with_unknown_color_mode( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.COLOR_TEMP @@ -567,7 +571,7 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") is None assert state.attributes.get("hs_color") is None @@ -578,7 +582,7 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") is None assert state.attributes.get("hs_color") is None @@ -632,7 +636,7 @@ async def test_controlling_state_via_topic( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") is None assert state.attributes.get("hs_color") is None @@ -653,7 +657,7 @@ async def test_controlling_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None # rgb color has priority + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority assert state.attributes.get("effect") == "colorloop" assert state.attributes.get("xy_color") == (0.323, 0.329) assert state.attributes.get("hs_color") == (0.0, 0.0) @@ -677,7 +681,7 @@ async def test_controlling_state_via_topic( 249, ) # temp converted to color assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") == 155 + assert state.attributes.get("color_temp_kelvin") == 6451 assert state.attributes.get("effect") == "colorloop" assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color @@ -794,7 +798,7 @@ async def test_controlling_state_via_topic2( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -820,7 +824,7 @@ async def test_controlling_state_via_topic2( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_mode") == "rgbww" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") == "colorloop" assert state.attributes.get("hs_color") == (20.552, 70.98) assert state.attributes.get("rgb_color") == (255, 136, 74) @@ -886,7 +890,7 @@ async def test_controlling_state_via_topic2( ) state = hass.states.get("light.test") assert state.attributes.get("color_mode") == "color_temp" - assert state.attributes.get("color_temp") == 155 + assert state.attributes.get("color_temp_kelvin") == 6451 # White async_fire_mqtt_message( @@ -965,7 +969,7 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -990,7 +994,7 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_mode") == "hs" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") == (15.765, 100.0) assert state.attributes.get("rgb_color") == (255, 67, 0) @@ -1012,7 +1016,7 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_mode") == "color_temp" - assert state.attributes.get("color_temp") == 353 + assert state.attributes.get("color_temp_kelvin") == 2832 assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") == (28.125, 61.661) assert state.attributes.get("rgb_color") == (255, 171, 98) @@ -1053,7 +1057,7 @@ async def test_sending_mqtt_commands_and_optimistic( "brightness": 95, "hs_color": [100, 100], "effect": "random", - "color_temp": 100, + "color_temp_kelvin": 10000, }, ) mock_restore_cache(hass, (fake_state,)) @@ -1065,7 +1069,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("brightness") == 95 assert state.attributes.get("hs_color") == (100, 100) assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp") is None # hs_color has priority + assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes expected_features = ( @@ -1083,7 +1087,7 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON - await common.async_turn_on(hass, "light.test", color_temp=90) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=11111) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", @@ -1095,7 +1099,7 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP - assert state.attributes.get("color_temp") == 90 + assert state.attributes.get("color_temp_kelvin") == 11111 await common.async_turn_off(hass, "light.test") @@ -1205,7 +1209,7 @@ async def test_sending_mqtt_commands_and_optimistic2( "on", { "brightness": 95, - "color_temp": 100, + "color_temp_kelvin": 10000, "color_mode": "rgb", "effect": "random", "hs_color": [100, 100], @@ -1223,7 +1227,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("brightness") == 95 assert state.attributes.get("color_mode") == "rgb" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") == "random" assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -1244,7 +1248,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.state == STATE_ON # Turn the light on with color temperature - await common.async_turn_on(hass, "light.test", color_temp=90) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=11111) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state":"ON","color_temp":90}'), @@ -2181,7 +2185,9 @@ async def test_white_scale( ], ) async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that invalid color/brightness/etc. values are ignored.""" await mqtt_mock_entry() @@ -2194,7 +2200,7 @@ async def test_invalid_values( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) # Turn on the light @@ -2212,7 +2218,7 @@ async def test_invalid_values( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None # Empty color value async_fire_mqtt_message( hass, @@ -2277,17 +2283,21 @@ async def test_invalid_values( ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 100 + assert state.attributes.get("color_temp_kelvin") == 10000 # Bad color temperature async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color_temp": "badValue"}' ) + assert ( + "Invalid color temp value 'badValue' received for entity light.test" + in caplog.text + ) # Color temperature should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 100 + assert state.attributes.get("color_temp_kelvin") == 10000 @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 59fd3eb88ed..4d2b93ff159 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -205,14 +205,16 @@ async def test_single_color_mode( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + await common.async_turn_on( + hass, "light.test", brightness=50, color_temp_kelvin=5208 + ) async_fire_mqtt_message(hass, "test_light", "on,50,192") color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -250,7 +252,7 @@ async def test_state_change_via_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "test_light_rgb", "on") @@ -259,7 +261,7 @@ async def test_state_change_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None async_fire_mqtt_message(hass, "test_light_rgb", "off") @@ -314,7 +316,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None assert state.attributes.get("effect") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) # turn on the light @@ -324,7 +326,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 128, 64) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None # rgb color has priority + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority assert state.attributes.get("effect") is None # turn on the light @@ -338,7 +340,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( 255, ) # temp converted to color assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") == 145 + assert state.attributes.get("color_temp_kelvin") == 6896 assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") == (0.317, 0.317) # temp converted to color assert state.attributes.get("hs_color") == ( @@ -432,7 +434,7 @@ async def test_sending_mqtt_commands_and_optimistic( "brightness": 95, "hs_color": [100, 100], "effect": "random", - "color_temp": 100, + "color_temp_kelvin": 10000, }, ) mock_restore_cache(hass, (fake_state,)) @@ -443,7 +445,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON assert state.attributes.get("hs_color") == (100, 100) assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp") is None # hs_color has priority + assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_off(hass, "light.test") @@ -463,14 +465,14 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp=70) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=14285) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", "on,,70,--,-", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 70 + assert state.attributes.get("color_temp_kelvin") == 14285 # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) @@ -594,7 +596,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( assert state.state == STATE_UNKNOWN # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp=70) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=14285) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", "on,,70,--,-", 0, False ) @@ -846,7 +848,7 @@ async def test_invalid_values( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -856,7 +858,7 @@ async def test_invalid_values( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None # hs_color has priority + assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("effect") == "rainbow" @@ -885,14 +887,14 @@ async def test_invalid_values( async_fire_mqtt_message(hass, "test_light_rgb", "on,,215,None-None-None") state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 215 + assert state.attributes.get("color_temp_kelvin") == 4651 # bad color temp values async_fire_mqtt_message(hass, "test_light_rgb", "on,,off,") # color temp should not have changed state = hass.states.get("light.test") - assert state.attributes.get("color_temp") == 215 + assert state.attributes.get("color_temp_kelvin") == 4651 # bad effect value async_fire_mqtt_message(hass, "test_light_rgb", "on,255,a-b-c,white") diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index fef62c33a93..c1c662048d7 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -27,8 +27,7 @@ from homeassistant.components.vacuum import ( SERVICE_RETURN_TO_BASE, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, + VacuumActivity, ) from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -313,7 +312,7 @@ async def test_status( }""" async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" assert state.attributes.get(ATTR_FAN_SPEED) == "max" @@ -326,7 +325,7 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 assert state.attributes.get(ATTR_FAN_SPEED) == "min" @@ -366,7 +365,7 @@ async def test_no_fan_vacuum( }""" async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 @@ -380,7 +379,7 @@ async def test_no_fan_vacuum( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None @@ -394,7 +393,7 @@ async def test_no_fan_vacuum( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 diff --git a/tests/components/myuplink/conftest.py b/tests/components/myuplink/conftest.py index 9ede11146ef..3ab186b61a8 100644 --- a/tests/components/myuplink/conftest.py +++ b/tests/components/myuplink/conftest.py @@ -15,10 +15,11 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.components.myuplink.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.json import json_loads -from .const import CLIENT_ID, CLIENT_SECRET +from .const import CLIENT_ID, CLIENT_SECRET, UNIQUE_ID from tests.common import MockConfigEntry, load_fixture @@ -33,7 +34,7 @@ def mock_expires_at() -> float: def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: """Return the default mocked config entry.""" config_entry = MockConfigEntry( - version=1, + minor_version=2, domain=DOMAIN, title="myUplink test", data={ @@ -48,6 +49,7 @@ def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry }, }, entry_id="myuplink_test", + unique_id=UNIQUE_ID, ) config_entry.add_to_hass(hass) return config_entry @@ -189,3 +191,21 @@ async def setup_platform( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() yield + + +@pytest.fixture +async def access_token(hass: HomeAssistant) -> str: + """Return a valid access token.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "sub": UNIQUE_ID, + "aud": [], + "scp": [ + "WRITESYSTEM", + "READSYSTEM", + "offline_access", + ], + "ou_code": "NA", + }, + ) diff --git a/tests/components/myuplink/const.py b/tests/components/myuplink/const.py index 6001cb151c0..4cb6db952f1 100644 --- a/tests/components/myuplink/const.py +++ b/tests/components/myuplink/const.py @@ -2,3 +2,4 @@ CLIENT_ID = "12345" CLIENT_SECRET = "67890" +UNIQUE_ID = "uid" diff --git a/tests/components/myuplink/fixtures/device_points_nibe_f730.json b/tests/components/myuplink/fixtures/device_points_nibe_f730.json index 99dd9c857e6..0a61ab05f21 100644 --- a/tests/components/myuplink/fixtures/device_points_nibe_f730.json +++ b/tests/components/myuplink/fixtures/device_points_nibe_f730.json @@ -1024,6 +1024,23 @@ "scaleValue": "1", "zoneId": null }, + { + "category": "F730 CU 3x400V", + "parameterId": "148072r", + "parameterName": "r start diff additional heat", + "parameterUnit": "DM", + "writable": false, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 700, + "strVal": "700DM", + "smartHomeCategories": [], + "minValue": 100, + "maxValue": 2000, + "stepValue": 10, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, { "category": "F730 CU 3x400V", "parameterId": "47011", @@ -1040,5 +1057,56 @@ "enumValues": [], "scaleValue": "1", "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47007", + "parameterName": "Excluded", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 1, + "strVal": "1", + "smartHomeCategories": ["sh-indoorSpOffsHeat"], + "minValue": -10, + "maxValue": 10, + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "99000", + "parameterName": "Excluded 2", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": "Hello", + "strVal": "Hello", + "smartHomeCategories": [], + "minValue": "", + "maxValue": "", + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47398", + "parameterName": "Room sensor set point value heating climate system 1", + "parameterUnit": "°C", + "writable": true, + "timestamp": "2024-12-11T13:23:12+00:00", + "value": 14.5, + "strVal": "14.5°C", + "smartHomeCategories": [], + "minValue": 50.0, + "maxValue": 350.0, + "stepValue": 5.0, + "enumValues": [], + "scaleValue": "0.1", + "zoneId": null } ] diff --git a/tests/components/myuplink/snapshots/test_binary_sensor.ambr b/tests/components/myuplink/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..755cae3c623 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_binary_sensor.ambr @@ -0,0 +1,326 @@ +# serializer version: 1 +# name: test_binary_sensor_states[binary_sensor.gotham_city_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '123456-7890-1234-has_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Gotham City Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-connection_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Gotham City Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_connectivity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-connection_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Gotham City Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_connectivity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Extern. adjust\xadment climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43161', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Extern. adjust\xadment climate system 1', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Extern. adjust\xadment climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43161', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Extern. adjust\xadment climate system 1', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump: Heating medium (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49995', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Pump: Heating medium (GP1)', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump: Heating medium (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49995', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Pump: Heating medium (GP1)', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_diagnostics.ambr b/tests/components/myuplink/snapshots/test_diagnostics.ambr index 1b3502c1f04..6fe6becff11 100644 --- a/tests/components/myuplink/snapshots/test_diagnostics.ambr +++ b/tests/components/myuplink/snapshots/test_diagnostics.ambr @@ -1085,6 +1085,23 @@ "scaleValue": "1", "zoneId": null }, + { + "category": "F730 CU 3x400V", + "parameterId": "148072r", + "parameterName": "r start diff additional heat", + "parameterUnit": "DM", + "writable": false, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 700, + "strVal": "700DM", + "smartHomeCategories": [], + "minValue": 100, + "maxValue": 2000, + "stepValue": 10, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, { "category": "F730 CU 3x400V", "parameterId": "47011", @@ -1101,6 +1118,57 @@ "enumValues": [], "scaleValue": "1", "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47007", + "parameterName": "Excluded", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 1, + "strVal": "1", + "smartHomeCategories": ["sh-indoorSpOffsHeat"], + "minValue": -10, + "maxValue": 10, + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "99000", + "parameterName": "Excluded 2", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": "Hello", + "strVal": "Hello", + "smartHomeCategories": [], + "minValue": "", + "maxValue": "", + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47398", + "parameterName": "Room sensor set point value heating climate system 1", + "parameterUnit": "°C", + "writable": true, + "timestamp": "2024-12-11T13:23:12+00:00", + "value": 14.5, + "strVal": "14.5°C", + "smartHomeCategories": [], + "minValue": 50.0, + "maxValue": 350.0, + "stepValue": 5.0, + "enumValues": [], + "scaleValue": "0.1", + "zoneId": null } ] @@ -2179,6 +2247,23 @@ "scaleValue": "1", "zoneId": null }, + { + "category": "F730 CU 3x400V", + "parameterId": "148072r", + "parameterName": "r start diff additional heat", + "parameterUnit": "DM", + "writable": false, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 700, + "strVal": "700DM", + "smartHomeCategories": [], + "minValue": 100, + "maxValue": 2000, + "stepValue": 10, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, { "category": "F730 CU 3x400V", "parameterId": "47011", @@ -2195,6 +2280,57 @@ "enumValues": [], "scaleValue": "1", "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47007", + "parameterName": "Excluded", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 1, + "strVal": "1", + "smartHomeCategories": ["sh-indoorSpOffsHeat"], + "minValue": -10, + "maxValue": 10, + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "99000", + "parameterName": "Excluded 2", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": "Hello", + "strVal": "Hello", + "smartHomeCategories": [], + "minValue": "", + "maxValue": "", + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47398", + "parameterName": "Room sensor set point value heating climate system 1", + "parameterUnit": "°C", + "writable": true, + "timestamp": "2024-12-11T13:23:12+00:00", + "value": 14.5, + "strVal": "14.5°C", + "smartHomeCategories": [], + "minValue": 50.0, + "maxValue": 350.0, + "stepValue": 5.0, + "enumValues": [], + "scaleValue": "0.1", + "zoneId": null } ] diff --git a/tests/components/myuplink/snapshots/test_number.ambr b/tests/components/myuplink/snapshots/test_number.ambr new file mode 100644 index 00000000000..c47d3c60295 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_number.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_number_states[platforms0][number.gotham_city_degree_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_degree_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_degree_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_degree_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_degree_minutes_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_degree_minutes_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_degree_minutes_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_degree_minutes_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating offset climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47011', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating offset climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47011', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Room sensor set point value heating climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47398', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Room sensor set point value heating climate system 1', + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Room sensor set point value heating climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47398', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Room sensor set point value heating climate system 1', + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_start_diff_additional_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City start diff additional heat', + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_start_diff_additional_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_start_diff_additional_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City start diff additional heat', + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_start_diff_additional_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_select.ambr b/tests/components/myuplink/snapshots/test_select.ambr new file mode 100644 index 00000000000..eff06bc7f2d --- /dev/null +++ b/tests/components/myuplink/snapshots/test_select.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_select_states[platforms0][select.gotham_city_comfort_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.gotham_city_comfort_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'comfort mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_states[platforms0][select.gotham_city_comfort_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City comfort mode', + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'context': , + 'entity_id': 'select.gotham_city_comfort_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Economy', + }) +# --- +# name: test_select_states[platforms0][select.gotham_city_comfort_mode_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.gotham_city_comfort_mode_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'comfort mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_states[platforms0][select.gotham_city_comfort_mode_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City comfort mode', + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'context': , + 'entity_id': 'select.gotham_city_comfort_mode_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Economy', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a5469dc9a77 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -0,0 +1,4767 @@ +# serializer version: 1 +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average outdoor temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40067', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Average outdoor temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average outdoor temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40067', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Average outdoor temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calculated supply climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43009', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Calculated supply climate system 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calculated supply climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43009', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Calculated supply climate system 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_condenser_bt12', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condenser (BT12)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40017', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Condenser (BT12)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_condenser_bt12', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_condenser_bt12_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condenser (BT12)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40017', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Condenser (BT12)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_condenser_bt12_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40079', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40079', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40081', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40081', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40083', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be3_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40083', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_compressor_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-41778', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Current compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_compressor_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_compressor_frequency_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-41778', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Current compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_compressor_frequency_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_fan_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current fan mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_mode', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43108', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current fan mode', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_fan_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_fan_mode_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current fan mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_mode', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43108', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current fan mode', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_fan_mode_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_hot_water_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hot water mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43109', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current hot water mode', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_hot_water_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_hot_water_mode_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hot water mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43109', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current hot water mode', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_hot_water_mode_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current outd temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40004', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Current outd temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-9.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current outd temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40004', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Current outd temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-9.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Decrease from reference value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43125', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Decrease from reference value', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Decrease from reference value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43125', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Decrease from reference value', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_defrosting_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrosting time', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43066', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Gotham City Defrosting time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_defrosting_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_defrosting_time_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrosting time', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43066', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Gotham City Defrosting time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_defrosting_time_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_degree_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_degree_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_degree_minutes_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_degree_minutes_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-42770', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49633', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-42770', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49633', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_discharge_bt14', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge (BT14)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40018', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Discharge (BT14)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_discharge_bt14', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_discharge_bt14_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge (BT14)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40018', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Discharge (BT14)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_discharge_bt14_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'dT Inverter - exh air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City dT Inverter - exh air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'dT Inverter - exh air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City dT Inverter - exh air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_evaporator_bt16', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Evaporator (BT16)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40020', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Evaporator (BT16)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_evaporator_bt16', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-14.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_evaporator_bt16_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Evaporator (BT16)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40020', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Evaporator (BT16)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_evaporator_bt16_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-14.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40025', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Exhaust air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40025', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Exhaust air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_extract_air_bt21', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air (BT21)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40026', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Extract air (BT21)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_extract_air_bt21', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_extract_air_bt21_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air (BT21)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40026', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Extract air (BT21)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_extract_air_bt21_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating medium pump speed (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43437', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating medium pump speed (GP1)', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '79', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating medium pump speed (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43437', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating medium pump speed (GP1)', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '79', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge current value ((BT12 | BT63))', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43116', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge current value ((BT12 | BT63))', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge current value ((BT12 | BT63))', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43116', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge current value ((BT12 | BT63))', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge set point value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43115', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge set point value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge set point value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43115', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge set point value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water charging (BT6)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40014', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water charging (BT6)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water charging (BT6)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40014', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water charging (BT6)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water top (BT7)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40013', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water top (BT7)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water top (BT7)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40013', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water top (BT7)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Int elec add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Int elec add heat', + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Active', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Int elec add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Int elec add heat', + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Active', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Int elec add heat raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Int elec add heat raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Int elec add heat raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Int elec add heat raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_inverter_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter temperature', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43140', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Inverter temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_inverter_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_inverter_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter temperature', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43140', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Inverter temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_inverter_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_liquid_line_bt15', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Liquid line (BT15)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40019', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Liquid line (BT15)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_liquid_line_bt15', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_liquid_line_bt15_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Liquid line (BT15)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40019', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Liquid line (BT15)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_liquid_line_bt15_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_max_compressor_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Max compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_max_compressor_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_max_compressor_frequency_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Max compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_max_compressor_frequency_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_min_compressor_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43122', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Min compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_min_compressor_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_min_compressor_frequency_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43122', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Min compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_min_compressor_frequency_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (EP15-BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40145', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (EP15-BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (EP15-BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40145', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (EP15-BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Priority', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Priority', + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Heating', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Priority', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Priority', + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Heating', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority_raw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prior\xadity raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Prior\xadity raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority_raw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority_raw_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prior\xadity raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Prior\xadity raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority_raw_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'r start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072r', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City r start diff additional heat', + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'r start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072r', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City r start diff additional heat', + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reference, air speed sensor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'airflow', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43124', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gotham City Reference, air speed sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reference, air speed sensor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'airflow', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43124', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gotham City Reference, air speed sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40012', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt3_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40012', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt62', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT62)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40048', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT62)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt62', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt62_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT62)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40048', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT62)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt62_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_room_temperature_bt50', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature (BT50)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40033', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Room temperature (BT50)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_room_temperature_bt50', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_room_temperature_bt50_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature (BT50)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40033', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Room temperature (BT50)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_room_temperature_bt50_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status compressor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Status compressor', + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Runs', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status compressor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Status compressor', + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Runs', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor_raw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status com\xadpressor raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Status com\xadpressor raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor_raw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor_raw_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status com\xadpressor raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Status com\xadpressor raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor_raw_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_suction_gas_bt17', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Suction gas (BT17)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40022', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Suction gas (BT17)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_suction_gas_bt17', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_suction_gas_bt17_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Suction gas (BT17)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40022', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Suction gas (BT17)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_suction_gas_bt17_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40008', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40008', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt61', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT61)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40047', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT61)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt61', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt61_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT61)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40047', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT61)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt61_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_time_factor_add_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time factor add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43081', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Time factor add heat', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_time_factor_add_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1686.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_time_factor_add_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time factor add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43081', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Time factor add heat', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_time_factor_add_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1686.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Value, air velocity sensor (BS1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40050', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Value, air velocity sensor (BS1)', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.5', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Value, air velocity sensor (BS1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40050', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Value, air velocity sensor (BS1)', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.5', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_switch.ambr b/tests/components/myuplink/snapshots/test_switch.ambr new file mode 100644 index 00000000000..5d621e661ee --- /dev/null +++ b/tests/components/myuplink/snapshots/test_switch.ambr @@ -0,0 +1,185 @@ +# serializer version: 1 +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_increased_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'In\xadcreased venti\xadlation', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost_ventilation', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50005', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City In\xadcreased venti\xadlation', + }), + 'context': , + 'entity_id': 'switch.gotham_city_increased_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_increased_ventilation_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'In\xadcreased venti\xadlation', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost_ventilation', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50005', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City In\xadcreased venti\xadlation', + }), + 'context': , + 'entity_id': 'switch.gotham_city_increased_ventilation_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_temporary_lux', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tempo\xadrary lux', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temporary_lux', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50004', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Tempo\xadrary lux', + }), + 'context': , + 'entity_id': 'switch.gotham_city_temporary_lux', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_temporary_lux_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tempo\xadrary lux', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temporary_lux', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50004', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Tempo\xadrary lux', + }), + 'context': , + 'entity_id': 'switch.gotham_city_temporary_lux_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/myuplink/test_binary_sensor.py b/tests/components/myuplink/test_binary_sensor.py index 128a4ebdde9..160530bcdab 100644 --- a/tests/components/myuplink/test_binary_sensor.py +++ b/tests/components/myuplink/test_binary_sensor.py @@ -1,57 +1,28 @@ -"""Tests for myuplink sensor module.""" +"""Tests for myuplink binary sensor module.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -import pytest +from syrupy import SnapshotAssertion -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -# Test one entity from each of binary_sensor classes. -@pytest.mark.parametrize( - ("entity_id", "friendly_name", "test_attributes", "expected_state"), - [ - ( - "binary_sensor.gotham_city_pump_heating_medium_gp1", - "Gotham City Pump: Heating medium (GP1)", - True, - STATE_ON, - ), - ( - "binary_sensor.gotham_city_connectivity", - "Gotham City Connectivity", - False, - STATE_ON, - ), - ( - "binary_sensor.gotham_city_alarm", - "Gotham City Pump: Alarm", - False, - STATE_OFF, - ), - ], -) -async def test_sensor_states( +async def test_binary_sensor_states( hass: HomeAssistant, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, - entity_id: str, - friendly_name: str, - test_attributes: bool, - expected_state: str, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor state.""" - await setup_integration(hass, mock_config_entry) + """Test binary sensor state.""" - state = hass.states.get(entity_id) - assert state is not None - assert state.state == expected_state - if test_attributes: - assert state.attributes == { - "friendly_name": friendly_name, - } + with patch("homeassistant.components.myuplink.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py index c24d26057de..0b8d0dba17a 100644 --- a/tests/components/myuplink/test_config_flow.py +++ b/tests/components/myuplink/test_config_flow.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from .const import CLIENT_ID +from .const import CLIENT_ID, UNIQUE_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -29,6 +29,7 @@ async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + access_token: str, setup_credentials, ) -> None: """Check full flow.""" @@ -59,7 +60,7 @@ async def test_full_flow( OAUTH2_TOKEN, json={ "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "access_token": access_token, "type": "Bearer", "expires_in": 60, }, @@ -68,41 +69,67 @@ async def test_full_flow( with patch( f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + assert result["data"]["auth_implementation"] == DOMAIN + assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == UNIQUE_ID + @pytest.mark.usefixtures("current_request_with_host") -async def test_flow_reauth( +@pytest.mark.parametrize( + ("unique_id", "scope", "expected_reason"), + [ + ( + UNIQUE_ID, + CURRENT_SCOPE, + "reauth_successful", + ), + ( + "wrong_uid", + CURRENT_SCOPE, + "account_mismatch", + ), + ( + UNIQUE_ID, + "READSYSTEM offline_access", + "reauth_successful", + ), + ], + ids=["reauth_only", "account_mismatch", "wrong_scope"], +) +async def test_flow_reauth_abort( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, setup_credentials: None, mock_config_entry: MockConfigEntry, + access_token: str, expires_at: float, + unique_id: str, + scope: str, + expected_reason: str, ) -> None: - """Test reauth step.""" + """Test reauth step with correct params and mismatches.""" - OLD_SCOPE = "READSYSTEM offline_access" - OLD_SCOPE_TOKEN = { + CURRENT_TOKEN = { "auth_implementation": DOMAIN, "token": { - "access_token": "Fake_token", - "scope": OLD_SCOPE, + "access_token": access_token, + "scope": scope, "expires_in": 86399, "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", "token_type": "Bearer", "expires_at": expires_at, }, } - assert mock_config_entry.data["token"]["scope"] == CURRENT_SCOPE assert hass.config_entries.async_update_entry( - mock_config_entry, data=OLD_SCOPE_TOKEN + mock_config_entry, data=CURRENT_TOKEN, unique_id=unique_id ) - assert mock_config_entry.data["token"]["scope"] == OLD_SCOPE - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await mock_config_entry.start_reauth_flow(hass) @@ -137,7 +164,7 @@ async def test_flow_reauth( OAUTH2_TOKEN, json={ "refresh_token": "updated-refresh-token", - "access_token": "updated-access-token", + "access_token": access_token, "type": "Bearer", "expires_in": "60", "scope": CURRENT_SCOPE, @@ -146,13 +173,104 @@ async def test_flow_reauth( with patch( f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True - ) as mock_setup: + ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "reauth_successful" + assert result.get("reason") == expected_reason + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("unique_id", "scope", "expected_reason"), + [ + ( + UNIQUE_ID, + CURRENT_SCOPE, + "reconfigure_successful", + ), + ( + "wrong_uid", + CURRENT_SCOPE, + "account_mismatch", + ), + ], + ids=["reauth_only", "account_mismatch"], +) +async def test_flow_reconfigure_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, + unique_id: str, + scope: str, + expected_reason: str, +) -> None: + """Test reauth step with correct params and mismatches.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "scope": scope, + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + } + assert hass.config_entries.async_update_entry( + mock_config_entry, data=CURRENT_TOKEN, unique_id=unique_id + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + f"&scope={CURRENT_SCOPE.replace(' ', '+')}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": "60", + "scope": CURRENT_SCOPE, + }, + ) + + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_reason assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - assert mock_config_entry.data["token"]["scope"] == CURRENT_SCOPE diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index b474db731d1..fda0d3526f9 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -4,17 +4,21 @@ import http import time from unittest.mock import MagicMock +from aiohttp import ClientConnectionError import pytest from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from . import setup_integration +from .const import UNIQUE_ID from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_load_unload_entry( @@ -70,6 +74,37 @@ async def test_expired_token_refresh_failure( assert mock_config_entry.state is expected_state +@pytest.mark.parametrize( + ("expires_at", "expected_state"), + [ + ( + time.time() - 3600, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=[ + "client_connection_error", + ], +) +async def test_expired_token_refresh_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a ClientError.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + exc=ClientConnectionError(), + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + @pytest.mark.parametrize( "load_systems_file", [load_fixture("systems.json", DOMAIN)], @@ -92,7 +127,90 @@ async def test_devices_multiple_created_count( mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that multiple device are created.""" + """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) assert len(device_registry.devices) == 2 + + +async def test_migrate_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_myuplink_client: MagicMock, + expires_at: float, + access_token: str, +) -> None: + """Test migration of config entry.""" + mock_entry_v1_1 = MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="myUplink test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "scope": "WRITESYSTEM READSYSTEM offline_access", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="myuplink_test", + ) + + await setup_integration(hass, mock_entry_v1_1) + assert mock_entry_v1_1.version == 1 + assert mock_entry_v1_1.minor_version == 2 + assert mock_entry_v1_1.unique_id == UNIQUE_ID + + +async def test_oaut2_scope_failure( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an incorrect OAuth2 scope fails.""" + + mock_config_entry.data["token"]["scope"] = "wrong_scope" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_device_remove_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + mock_myuplink_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ) + }, + ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, mock_config_entry.entry_id) + assert not response["success"] + + old_device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "OLD-DEVICE-UUID")}, + ) + response = await client.remove_device( + old_device_entry.id, mock_config_entry.entry_id + ) + assert response["success"] diff --git a/tests/components/myuplink/test_number.py b/tests/components/myuplink/test_number.py index 4106af1b5b9..ef7b1749782 100644 --- a/tests/components/myuplink/test_number.py +++ b/tests/components/myuplink/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.number import SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -11,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from tests.common import MockConfigEntry, snapshot_platform + TEST_PLATFORM = Platform.NUMBER pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) @@ -31,24 +34,6 @@ async def test_entity_registry( assert entry.unique_id == ENTITY_UID -async def test_attributes( - hass: HomeAssistant, - mock_myuplink_client: MagicMock, - setup_platform: None, -) -> None: - """Test the entity attributes are correct.""" - - state = hass.states.get(ENTITY_ID) - assert state.state == "1.0" - assert state.attributes == { - "friendly_name": ENTITY_FRIENDLY_NAME, - "min": -10.0, - "max": 10.0, - "mode": "auto", - "step": 1.0, - } - - async def test_set_value( hass: HomeAssistant, mock_myuplink_client: MagicMock, @@ -98,3 +83,16 @@ async def test_entity_registry_smo20( entry = entity_registry.async_get("number.gotham_city_change_in_curve") assert entry.unique_id == "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47028" + + +async def test_number_states( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test number entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_select.py b/tests/components/myuplink/test_select.py index 7ad2d17cb5d..f1797ebe5ad 100644 --- a/tests/components/myuplink/test_select.py +++ b/tests/components/myuplink/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest +from syrupy import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, @@ -15,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from tests.common import MockConfigEntry, snapshot_platform + TEST_PLATFORM = Platform.SELECT pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) @@ -23,27 +26,6 @@ ENTITY_FRIENDLY_NAME = "Gotham City comfort mode" ENTITY_UID = "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041" -async def test_select_entity( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_myuplink_client: MagicMock, - setup_platform: None, -) -> None: - """Test that the entities are registered in the entity registry.""" - - entry = entity_registry.async_get(ENTITY_ID) - assert entry.unique_id == ENTITY_UID - - # Test the select attributes are correct. - - state = hass.states.get(ENTITY_ID) - assert state.state == "Economy" - assert state.attributes == { - "options": ["Smart control", "Economy", "Normal", "Luxury"], - "friendly_name": ENTITY_FRIENDLY_NAME, - } - - async def test_selecting( hass: HomeAssistant, mock_myuplink_client: MagicMock, @@ -87,3 +69,16 @@ async def test_entity_registry_smo20( entry = entity_registry.async_get("select.gotham_city_all") assert entry.unique_id == "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47660" + + +async def test_select_states( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test select entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_sensor.py b/tests/components/myuplink/test_sensor.py index 8fecb787122..98cdfc322da 100644 --- a/tests/components/myuplink/test_sensor.py +++ b/tests/components/myuplink/test_sensor.py @@ -1,28 +1,30 @@ """Tests for myuplink sensor module.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_states( hass: HomeAssistant, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test sensor state.""" - await setup_integration(hass, mock_config_entry) - state = hass.states.get("sensor.gotham_city_average_outdoor_temp_bt1") - assert state is not None - assert state.state == "-12.2" - assert state.attributes == { - "friendly_name": "Gotham City Average outdoor temp (BT1)", - "device_class": "temperature", - "state_class": "measurement", - "unit_of_measurement": "°C", - } + with patch("homeassistant.components.myuplink.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_switch.py b/tests/components/myuplink/test_switch.py index 5e309e7152e..82d381df7fc 100644 --- a/tests/components/myuplink/test_switch.py +++ b/tests/components/myuplink/test_switch.py @@ -4,18 +4,20 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest +from syrupy import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from tests.common import MockConfigEntry, snapshot_platform + TEST_PLATFORM = Platform.SWITCH pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) @@ -36,20 +38,6 @@ async def test_entity_registry( assert entry.unique_id == ENTITY_UID -async def test_attributes( - hass: HomeAssistant, - mock_myuplink_client: MagicMock, - setup_platform: None, -) -> None: - """Test the switch attributes are correct.""" - - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - assert state.attributes == { - "friendly_name": ENTITY_FRIENDLY_NAME, - } - - @pytest.mark.parametrize( ("service"), [ @@ -109,3 +97,16 @@ async def test_entity_registry_smo20( entry = entity_registry.async_get(ENTITY_ID) assert entry.unique_id == ENTITY_UID + + +async def test_switch_states( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test switch entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 463556ec657..4ea7e30bcf9 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -646,6 +646,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'netatmo', 'version': 1, diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 827d6aeb6e5..23f42fee077 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Fake Profile', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nice_go/fixtures/get_all_barriers.json b/tests/components/nice_go/fixtures/get_all_barriers.json index 84799e0dd32..5a7607612c1 100644 --- a/tests/components/nice_go/fixtures/get_all_barriers.json +++ b/tests/components/nice_go/fixtures/get_all_barriers.json @@ -11,7 +11,6 @@ ], "state": { "deviceId": "1", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 1", "autoDisabled": false, @@ -42,7 +41,6 @@ ], "state": { "deviceId": "2", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 2", "autoDisabled": false, @@ -73,7 +71,6 @@ ], "state": { "deviceId": "3", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 3", "autoDisabled": false, @@ -101,7 +98,6 @@ ], "state": { "deviceId": "4", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 4", "autoDisabled": false, diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index f4ba363a421..b33726d2b72 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -60,6 +60,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nice_go/test_init.py b/tests/components/nice_go/test_init.py index 4eb3851516e..051c6623b23 100644 --- a/tests/components/nice_go/test_init.py +++ b/tests/components/nice_go/test_init.py @@ -81,7 +81,6 @@ async def test_firmware_update_required( "displayName": "test-display-name", "migrationStatus": "NOT_STARTED", }, - desired=None, connectionState=None, version=None, timestamp=None, diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index d1c1972c568..9b7ab4b2afa 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from datetime import datetime import json from typing import Any @@ -23,6 +24,13 @@ from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def no_sleep() -> AsyncGenerator[None]: + """No sleeping.""" + with patch("homeassistant.components.nordpool.coordinator.asyncio.sleep"): + yield + + @pytest.fixture async def load_int( hass: HomeAssistant, get_data: DeliveryPeriodData diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index d2d912b1b99..68534237dee 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -58,7 +58,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 4 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE mock_data.reset_mock() @@ -68,7 +68,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 4 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Authentication error" in caplog.text @@ -79,7 +79,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 4 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text @@ -90,7 +90,7 @@ async def test_coordinator( freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 4 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Response error" in caplog.text diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index c7a305c8a40..5c2d138cb34 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -6,6 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,3 +24,39 @@ async def test_sensor( """Test the Nord Pool sensor.""" await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) + + +@pytest.mark.freeze_time("2024-11-05T23:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) -> None: + """Test the Nord Pool sensor.""" + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.28914" + assert last_price.state == "0.28914" + assert next_price.state == STATE_UNKNOWN + + +@pytest.mark.freeze_time("2024-11-05T00:00:00+01:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_no_previous_price( + hass: HomeAssistant, load_int: ConfigEntry +) -> None: + """Test the Nord Pool sensor.""" + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.25073" + assert last_price.state == STATE_UNKNOWN + assert next_price.state == "0.07636" diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 890ce2dfc4a..c1d1bd1bb2e 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -37,6 +37,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "bridges": [ diff --git a/tests/components/ohme/__init__.py b/tests/components/ohme/__init__.py new file mode 100644 index 00000000000..7c00bedbd1e --- /dev/null +++ b/tests/components/ohme/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Ohme integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Ohme integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py new file mode 100644 index 00000000000..90395feeb6b --- /dev/null +++ b/tests/components/ohme/conftest.py @@ -0,0 +1,64 @@ +"""Provide common fixtures.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ohme import ChargerPower, ChargerStatus +import pytest + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ohme.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="test@example.com", + domain=DOMAIN, + version=1, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter2", + }, + ) + + +@pytest.fixture +def mock_client(): + """Fixture to mock the OhmeApiClient.""" + with ( + patch( + "homeassistant.components.ohme.config_flow.OhmeApiClient", + autospec=True, + ) as client, + patch( + "homeassistant.components.ohme.OhmeApiClient", + new=client, + ), + ): + client = client.return_value + client.async_login.return_value = True + client.status = ChargerStatus.CHARGING + client.power = ChargerPower(0, 0, 0, 0) + client.serial = "chargerid" + client.ct_connected = True + client.energy = 1000 + client.device_info = { + "name": "Ohme Home Pro", + "model": "Home Pro", + "sw_version": "v2.65", + } + yield client diff --git a/tests/components/ohme/snapshots/test_button.ambr b/tests/components/ohme/snapshots/test_button.ambr new file mode 100644 index 00000000000..32de16208f4 --- /dev/null +++ b/tests/components/ohme/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_buttons[button.ohme_home_pro_approve_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ohme_home_pro_approve_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Approve charge', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'approve', + 'unique_id': 'chargerid_approve', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.ohme_home_pro_approve_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Approve charge', + }), + 'context': , + 'entity_id': 'button.ohme_home_pro_approve_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr new file mode 100644 index 00000000000..e3ed339b78a --- /dev/null +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ohme', + 'chargerid', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ohme', + 'model': 'Home Pro', + 'model_id': None, + 'name': 'Ohme Home Pro', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'chargerid', + 'suggested_area': None, + 'sw_version': 'v2.65', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fbffa5b7e5d --- /dev/null +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -0,0 +1,268 @@ +# serializer version: 1 +# name: test_sensors[sensor.ohme_home_pro_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CT current', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ct_current', + 'unique_id': 'chargerid_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Ohme Home Pro CT current', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Ohme Home Pro Current', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ohme Home Pro Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ohme Home Pro Power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'pending_approval', + 'charging', + 'plugged_in', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'chargerid_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Ohme Home Pro Status', + 'options': list([ + 'unplugged', + 'pending_approval', + 'charging', + 'plugged_in', + ]), + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- diff --git a/tests/components/ohme/test_button.py b/tests/components/ohme/test_button.py new file mode 100644 index 00000000000..1728563b2e9 --- /dev/null +++ b/tests/components/ohme/test_button.py @@ -0,0 +1,79 @@ +"""Tests for sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from ohme import ChargerStatus +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme buttons.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_available( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that button shows as unavailable when a charge is not pending approval.""" + mock_client.status = ChargerStatus.PENDING_APPROVAL + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("button.ohme_home_pro_approve_charge") + assert state.state == STATE_UNKNOWN + + mock_client.status = ChargerStatus.PLUGGED_IN + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("button.ohme_home_pro_approve_charge") + assert state.state == STATE_UNAVAILABLE + + +async def test_button_press( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the button press action.""" + mock_client.status = ChargerStatus.PENDING_APPROVAL + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.ohme_home_pro_approve_charge", + }, + blocking=True, + ) + + assert len(mock_client.async_approve_charge.mock_calls) == 1 diff --git a/tests/components/ohme/test_config_flow.py b/tests/components/ohme/test_config_flow.py new file mode 100644 index 00000000000..bb7ecc00bdc --- /dev/null +++ b/tests/components/ohme/test_config_flow.py @@ -0,0 +1,184 @@ +"""Tests for the config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from ohme import ApiException, AuthException +import pytest + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: MagicMock +) -> None: + """Test config flow.""" + + # Initial form load + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Successful login + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter2"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter2", + } + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_config_flow_fail( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test config flow errors.""" + + # Initial form load + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Failed login + mock_client.async_login.side_effect = test_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # End with CREATE_ENTRY + mock_client.async_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + } + + +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Ensure we can't add the same account twice.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter3", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_form(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test reauth form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + assert not result["errors"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_reauth_fail( + hass: HomeAssistant, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test reauth errors.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + # Initial form load + result = await entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Failed login + mock_client.async_login.side_effect = test_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # End with success + mock_client.async_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/ohme/test_init.py b/tests/components/ohme/test_init.py new file mode 100644 index 00000000000..0f4c7cd64ee --- /dev/null +++ b/tests/components/ohme/test_init.py @@ -0,0 +1,47 @@ +"""Test init of Ohme integration.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device( + mock_client: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot the device from registry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(DOMAIN, mock_client.serial)}) + assert device + assert device == snapshot diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py new file mode 100644 index 00000000000..21f9f06f963 --- /dev/null +++ b/tests/components/ohme/test_sensor.py @@ -0,0 +1,59 @@ +"""Tests for sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from ohme import ApiException +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that sensors show as unavailable after a coordinator failure.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.ohme_home_pro_energy") + assert state.state == "1.0" + + mock_client.async_get_charge_session.side_effect = ApiException + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.ohme_home_pro_energy") + assert state.state == STATE_UNAVAILABLE + + mock_client.async_get_charge_session.side_effect = None + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.ohme_home_pro_energy") + assert state.state == "1.0" diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 5ad4cf2ef4b..261b081060c 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -363,7 +363,7 @@ 'supported_features': 0, 'translation_key': 'counter_id', 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': 'count', + 'unit_of_measurement': None, }), EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -396,7 +396,7 @@ 'supported_features': 0, 'translation_key': 'counter_id', 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': 'count', + 'unit_of_measurement': None, }), ]) # --- @@ -408,7 +408,6 @@ 'friendly_name': '1D.111111111111 Counter A', 'raw_value': 251123.0, 'state_class': , - 'unit_of_measurement': 'count', }), 'context': , 'entity_id': 'sensor.1d_111111111111_counter_a', @@ -423,7 +422,6 @@ 'friendly_name': '1D.111111111111 Counter B', 'raw_value': 248125.0, 'state_class': , - 'unit_of_measurement': 'count', }), 'context': , 'entity_id': 'sensor.1d_111111111111_counter_b', @@ -531,7 +529,7 @@ 'supported_features': 0, 'translation_key': 'counter_id', 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': 'count', + 'unit_of_measurement': None, }), EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -564,7 +562,7 @@ 'supported_features': 0, 'translation_key': 'counter_id', 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': 'count', + 'unit_of_measurement': None, }), ]) # --- @@ -576,7 +574,6 @@ 'friendly_name': '1D.111111111111 Counter A', 'raw_value': 251123.0, 'state_class': , - 'unit_of_measurement': 'count', }), 'context': , 'entity_id': 'sensor.1d_111111111111_counter_a', @@ -591,7 +588,6 @@ 'friendly_name': '1D.111111111111 Counter B', 'raw_value': 248125.0, 'state_class': , - 'unit_of_measurement': 'count', }), 'context': , 'entity_id': 'sensor.1d_111111111111_counter_b', diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 8900f189aea..064075d109e 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -19,6 +19,16 @@ def create_receiver_info(id: int) -> ReceiverInfo: ) +def create_connection(id: int) -> Mock: + """Create an mock connection object for testing.""" + connection = Mock() + connection.host = f"host {id}" + connection.port = 0 + connection.name = f"type {id}" + connection.identifier = f"id{id}" + return connection + + def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: """Create a config entry from receiver info.""" data = {CONF_HOST: info.host} diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index c37966e3bae..abbe39dd966 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -1,25 +1,16 @@ """Configure tests for the Onkyo integration.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest from homeassistant.components.onkyo.const import DOMAIN +from . import create_connection + from tests.common import MockConfigEntry -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.onkyo.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Create Onkyo entry in Home Assistant.""" @@ -28,3 +19,56 @@ def mock_config_entry() -> MockConfigEntry: title="Onkyo", data={}, ) + + +@pytest.fixture(autouse=True) +def patch_timeouts(): + """Patch timeouts to avoid tests waiting.""" + with patch.multiple( + "homeassistant.components.onkyo.receiver", + DEVICE_INTERVIEW_TIMEOUT=0, + DEVICE_DISCOVERY_TIMEOUT=0, + ): + yield + + +@pytest.fixture +async def default_mock_discovery(): + """Mock discovery with a single device.""" + + async def mock_discover(host=None, discovery_callback=None, timeout=0): + await discovery_callback(create_connection(1)) + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, + ): + yield + + +@pytest.fixture +async def stub_mock_discovery(): + """Mock discovery with no devices.""" + + async def mock_discover(host=None, discovery_callback=None, timeout=0): + pass + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, + ): + yield + + +@pytest.fixture +async def empty_mock_discovery(): + """Mock discovery with an empty connection.""" + + async def mock_discover(host=None, discovery_callback=None, timeout=0): + await discovery_callback(None) + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, + ): + yield diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index f230ab124bd..1ee0bfdf9c5 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -20,12 +20,13 @@ from homeassistant.data_entry_flow import FlowResultType, InvalidData from . import ( create_config_entry_from_info, + create_connection, create_empty_config_entry, create_receiver_info, setup_integration, ) -from tests.common import Mock, MockConfigEntry +from tests.common import MockConfigEntry async def test_user_initial_menu(hass: HomeAssistant) -> None: @@ -40,9 +41,8 @@ async def test_user_initial_menu(hass: HomeAssistant) -> None: assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} -async def test_manual_valid_host(hass: HomeAssistant) -> None: +async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: """Test valid host entered.""" - init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -53,30 +53,17 @@ async def test_manual_valid_host(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" - mock_info.host = "mock_host" - mock_info.model_name = "mock_model" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "host 1"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert ( - select_result["description_placeholders"]["name"] - == "mock_model (mock_host)" - ) + assert select_result["step_id"] == "configure_receiver" + assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" -async def test_manual_invalid_host(hass: HomeAssistant) -> None: +async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: """Test invalid host entered.""" - init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -87,19 +74,18 @@ async def test_manual_invalid_host(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", return_value=None - ): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "cannot_connect" -async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: +async def test_manual_valid_host_unexpected_error( + hass: HomeAssistant, empty_mock_discovery +) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( @@ -112,55 +98,49 @@ async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - side_effect=Exception(), - ): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "unknown" -async def test_discovery_and_no_devices_discovered(hass: HomeAssistant) -> None: +async def test_discovery_and_no_devices_discovered( + hass: HomeAssistant, stub_mock_discovery +) -> None: """Test initial menu.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_discover", return_value=[] - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + form_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "no_devices_found" + assert form_result["type"] is FlowResultType.ABORT + assert form_result["reason"] == "no_devices_found" -async def test_discovery_with_exception(hass: HomeAssistant) -> None: +async def test_discovery_with_exception( + hass: HomeAssistant, empty_mock_discovery +) -> None: """Test discovery which throws an unexpected exception.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_discover", - side_effect=Exception(), - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "unknown" + form_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) + + assert form_result["type"] is FlowResultType.ABORT + assert form_result["reason"] == "unknown" async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None: @@ -170,13 +150,12 @@ async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> Non context={"source": SOURCE_USER}, ) - infos = [create_receiver_info(1), create_receiver_info(2)] + async def mock_discover(discovery_callback, timeout): + await discovery_callback(create_connection(1)) + await discovery_callback(create_connection(2)) with ( - patch( - "homeassistant.components.onkyo.config_flow.async_discover", - return_value=infos, - ), + patch("pyeiscp.Connection.discover", new=mock_discover), # Fake it like the first entry was already added patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]), ): @@ -185,12 +164,12 @@ async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> Non {"next_step_id": "eiscp_discovery"}, ) - assert form_result["type"] is FlowResultType.FORM + assert form_result["type"] is FlowResultType.FORM - assert form_result["data_schema"] is not None - schema = form_result["data_schema"].schema - container = schema["device"].container - assert container == {"id2": "type 2 (host 2)"} + assert form_result["data_schema"] is not None + schema = form_result["data_schema"].schema + container = schema["device"].container + assert container == {"id2": "type 2 (host 2)"} async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: @@ -200,14 +179,11 @@ async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: context={"source": SOURCE_USER}, ) - infos = [create_receiver_info(42), create_receiver_info(0)] + async def mock_discover(discovery_callback, timeout): + await discovery_callback(create_connection(42)) + await discovery_callback(create_connection(0)) - with ( - patch( - "homeassistant.components.onkyo.config_flow.async_discover", - return_value=infos, - ), - ): + with patch("pyeiscp.Connection.discover", new=mock_discover): form_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], {"next_step_id": "eiscp_discovery"}, @@ -218,11 +194,13 @@ async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: user_input={"device": "id42"}, ) - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" + assert select_result["step_id"] == "configure_receiver" + assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" -async def test_configure_empty_source_list(hass: HomeAssistant) -> None: +async def test_configure_empty_source_list( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configuration with no sources set.""" init_result = await hass.config_entries.flow.async_init( @@ -235,29 +213,22 @@ async def test_configure_empty_source_list(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + configure_result = await hass.config_entries.flow.async_configure( + select_result["flow_id"], + user_input={"volume_resolution": 200, "input_sources": []}, + ) - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": []}, - ) - - assert configure_result["errors"] == { - "input_sources": "empty_input_source_list" - } + assert configure_result["errors"] == {"input_sources": "empty_input_source_list"} -async def test_configure_no_resolution(hass: HomeAssistant) -> None: +async def test_configure_no_resolution( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configure with no resolution set.""" init_result = await hass.config_entries.flow.async_init( @@ -270,26 +241,21 @@ async def test_configure_no_resolution(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + with pytest.raises(InvalidData): + await hass.config_entries.flow.async_configure( + select_result["flow_id"], + user_input={"input_sources": ["TV"]}, ) - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"input_sources": ["TV"]}, - ) - -async def test_configure_resolution_set(hass: HomeAssistant) -> None: +async def test_configure_resolution_set( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configure with specified resolution.""" init_result = await hass.config_entries.flow.async_init( @@ -302,16 +268,10 @@ async def test_configure_resolution_set(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - receiver_info = create_receiver_info(1) - - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=receiver_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) configure_result = await hass.config_entries.flow.async_configure( select_result["flow_id"], @@ -322,7 +282,9 @@ async def test_configure_resolution_set(hass: HomeAssistant) -> None: assert configure_result["options"]["volume_resolution"] == 200 -async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: +async def test_configure_invalid_resolution_set( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configure with invalid resolution.""" init_result = await hass.config_entries.flow.async_init( @@ -335,26 +297,19 @@ async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + with pytest.raises(InvalidData): + await hass.config_entries.flow.async_configure( + select_result["flow_id"], + user_input={"volume_resolution": 42, "input_sources": ["TV"]}, ) - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 42, "input_sources": ["TV"]}, - ) - -async def test_reconfigure(hass: HomeAssistant) -> None: +async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: """Test the reconfigure config flow.""" receiver_info = create_receiver_info(1) config_entry = create_config_entry_from_info(receiver_info) @@ -368,14 +323,10 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=receiver_info, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info.host} - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"host": receiver_info.host} + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "configure_receiver" @@ -403,14 +354,18 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: result = await config_entry.start_reconfigure_flow(hass) - receiver_info_2 = create_receiver_info(2) + mock_connection = create_connection(2) + + # Create mock discover that calls callback immediately + async def mock_discover(host, discovery_callback, timeout): + await discovery_callback(mock_connection) with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=receiver_info_2, + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info_2.host} + result["flow_id"], user_input={"host": mock_connection.host} ) await hass.async_block_till_done() @@ -455,12 +410,10 @@ async def test_import_fail( error: str, ) -> None: """Test import flow failed.""" - with ( - patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=None, - side_effect=exception, - ), + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + side_effect=exception, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input @@ -503,6 +456,15 @@ async def test_import_success( } +@pytest.mark.parametrize( + "ignore_translations", + [ + [ # The schema is dynamically created from input sources + "component.onkyo.options.step.init.data.TV", + "component.onkyo.options.step.init.data_description.TV", + ] + ], +) async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test options flow.""" diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index c8a9ff75d62..c3938efcbb6 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py new file mode 100644 index 00000000000..209e7cbccef --- /dev/null +++ b/tests/components/onvif/test_parsers.py @@ -0,0 +1,335 @@ +"""Test ONVIF parsers.""" + +import datetime +import os + +import onvif +import onvif.settings +from zeep import Client +from zeep.transports import Transport + +from homeassistant.components.onvif import models, parsers +from homeassistant.core import HomeAssistant + +TEST_UID = "test-unique-id" + + +async def get_event(notification_data: dict) -> models.Event: + """Take in a zeep dict, run it through the parser, and return an Event. + + When the parser encounters an unknown topic that it doesn't know how to parse, + it outputs a message 'No registered handler for event from ...' along with a + print out of the serialized xml message from zeep. If it tries to parse and + can't, it prints out 'Unable to parse event from ...' along with the same + serialized message. This method can take the output directly from these log + messages and run them through the parser, which makes it easy to add new unit + tests that verify the message can now be parsed. + """ + zeep_client = Client( + f"{os.path.dirname(onvif.__file__)}/wsdl/events.wsdl", + wsse=None, + transport=Transport(), + ) + + notif_msg_type = zeep_client.get_type("ns5:NotificationMessageHolderType") + assert notif_msg_type is not None + notif_msg = notif_msg_type(**notification_data) + assert notif_msg is not None + + # The xsd:any type embedded inside the message doesn't parse, so parse it manually. + msg_elem = zeep_client.get_element("ns8:Message") + assert msg_elem is not None + msg_data = msg_elem(**notification_data["Message"]["_value_1"]) + assert msg_data is not None + notif_msg.Message._value_1 = msg_data + + parser = parsers.PARSERS.get(notif_msg.Topic._value_1) + assert parser is not None + + return await parser(TEST_UID, notif_msg) + + +async def test_line_detector_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/LineDetector/Crossed.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": {"_value_1": None, "_attr_1": None}, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/LineDetector/Crossed", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "xx.xx.xx.xx/onvif/event/alarm", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "video_source_config1", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "analytics_video_source", + }, + {"Name": "Rule", "Value": "MyLineDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "ObjectId", "Value": "0"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime(2020, 5, 24, 7, 24, 47), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "sensor" + assert event.value == "0" + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/LineDetector/" + "Crossed_video_source_config1_analytics_video_source_MyLineDetectorRule" + ) + + +async def test_tapo_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "_attr_1": None, + }, + "Extension": None, + "Key": None, + "PropertyOperation": "Changed", + "Source": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + { + "Name": "Rule", + "Value": "MyTPSmartEventDetectorRule", + }, + ], + "_attr_1": None, + }, + "UtcTime": datetime.datetime( + 2024, 11, 2, 0, 33, 11, tzinfo=datetime.UTC + ), + "_attr_1": {}, + } + }, + "ProducerReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:5656/event", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "SubscriptionReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:2020/event-0_2020", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "Topic": { + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + "_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent", + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/" + "TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "_attr_1": None, + }, + "Extension": None, + "Key": None, + "PropertyOperation": "Changed", + "Source": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "_attr_1": None, + }, + "UtcTime": datetime.datetime( + 2024, 11, 3, 18, 40, 43, tzinfo=datetime.UTC + ), + "_attr_1": {}, + } + }, + "ProducerReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:5656/event", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "SubscriptionReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:2020/event-0_2020", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "Topic": { + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/PeopleDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: + """Tests async_parse_tplink_detector with missing fields.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "_attr_1": None, + }, + } + }, + "Topic": { + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) + + assert event is None + + +async def test_tapo_unknown_type(hass: HomeAssistant) -> None: + """Tests async_parse_tplink_detector with unknown event type.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsNotPerson", "Value": "true"}], + "_attr_1": None, + }, + "Source": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + }, + } + }, + "Topic": { + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) + + assert event is None diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 61b68b5ad90..03b392b3e7b 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -39,6 +39,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "protection_window": { diff --git a/tests/components/p1_monitor/snapshots/test_init.ambr b/tests/components/p1_monitor/snapshots/test_init.ambr index d0a676fce1b..83684e153c9 100644 --- a/tests/components/p1_monitor/snapshots/test_init.ambr +++ b/tests/components/p1_monitor/snapshots/test_init.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, @@ -38,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index ec58afc324a..fad535df914 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -66,6 +66,7 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.has_on_off_switch = True mock_client.has_pellet_level = False mock_client.connected = True + mock_client.status = 6 mock_client.is_heating = True mock_client.room_temperature = 18 mock_client.T1 = 21.5 @@ -87,6 +88,8 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.set_fan_silent.return_value = True mock_client.set_fan_high.return_value = True mock_client.set_fan_auto.return_value = True + mock_client.set_power_mode.return_value = True + mock_client.power_mode = 3 mock_client.list_temperatures.return_value = [ TemperatureDefinition( description_key=TemperatureDescriptionKey.ROOM_TEMP, diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr new file mode 100644 index 00000000000..0a25a1cfa8b --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_all_entities[number.stove_combustion_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_combustion_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Combustion power', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'combustion_power', + 'unique_id': '11:22:33:44:55:66-combustion_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_combustion_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Stove Combustion power', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_combustion_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_sensor.ambr b/tests/components/palazzetti/snapshots/test_sensor.ambr index 107b818f195..aa98f3a4f59 100644 --- a/tests/components/palazzetti/snapshots/test_sensor.ambr +++ b/tests/components/palazzetti/snapshots/test_sensor.ambr @@ -305,6 +305,152 @@ 'state': '21.5', }) # --- +# name: test_all_entities[sensor.stove_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'off_timer', + 'test_fire', + 'heatup', + 'fueling', + 'ign_test', + 'burning', + 'burning_mod', + 'unknown', + 'cool_fluid', + 'fire_stop', + 'clean_fire', + 'cooling', + 'cleanup', + 'ecomode', + 'chimney_alarm', + 'grate_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'off', + 'fueling', + 'ign_test', + 'burning', + 'firewood_finished', + 'cooling', + 'clean_fire', + 'general_error', + 'general_error', + 'door_open', + 'temp_too_high', + 'cleaning_warning', + 'fuel_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'general_error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '11:22:33:44:55:66-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stove_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Stove Status', + 'options': list([ + 'off', + 'off_timer', + 'test_fire', + 'heatup', + 'fueling', + 'ign_test', + 'burning', + 'burning_mod', + 'unknown', + 'cool_fluid', + 'fire_stop', + 'clean_fire', + 'cooling', + 'cleanup', + 'ecomode', + 'chimney_alarm', + 'grate_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'off', + 'fueling', + 'ign_test', + 'burning', + 'firewood_finished', + 'cooling', + 'clean_fire', + 'general_error', + 'general_error', + 'door_open', + 'temp_too_high', + 'cleaning_warning', + 'fuel_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'general_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.stove_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'burning', + }) +# --- # name: test_all_entities[sensor.stove_tank_water_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py new file mode 100644 index 00000000000..939c7c72c19 --- /dev/null +++ b/tests/components/palazzetti/test_number.py @@ -0,0 +1,72 @@ +"""Tests for the Palazzetti sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from pypalazzetti.exceptions import CommunicationError, ValidationError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "number.stove_combustion_power" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_set_data( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number data via service call.""" + await setup_integration(hass, mock_config_entry) + + # Set value: Success + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_power_mode.assert_called_once_with(1) + mock_palazzetti_client.set_on.reset_mock() + + # Set value: Error + mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_on.reset_mock() + + mock_palazzetti_client.set_power_mode.side_effect = ValidationError() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + blocking=True, + ) diff --git a/tests/components/peco/test_sensor.py b/tests/components/peco/test_sensor.py index 9cbef9fa1e6..4c9a3fca104 100644 --- a/tests/components/peco/test_sensor.py +++ b/tests/components/peco/test_sensor.py @@ -39,7 +39,7 @@ async def test_sensor_available( "peco.PecoOutageApi.get_outage_totals", return_value=OutageResults( customers_out=123, - percent_customers_out=15.589, + percent_customers_out=15, outage_count=456, customers_served=789, ), @@ -74,7 +74,7 @@ async def test_sensor_available( "peco.PecoOutageApi.get_outage_count", return_value=OutageResults( customers_out=123, - percent_customers_out=15.589, + percent_customers_out=15, outage_count=456, customers_served=789, ), diff --git a/tests/components/pegel_online/snapshots/test_diagnostics.ambr b/tests/components/pegel_online/snapshots/test_diagnostics.ambr index 1e55805f867..d0fdc81acb4 100644 --- a/tests/components/pegel_online/snapshots/test_diagnostics.ambr +++ b/tests/components/pegel_online/snapshots/test_diagnostics.ambr @@ -31,6 +31,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '70272185-xxxx-xxxx-xxxx-43bea330dcae', 'version': 1, diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr index 4f7a6176634..53db95f0534 100644 --- a/tests/components/philips_js/snapshots/test_diagnostics.ambr +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -94,6 +94,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 80d05961813..4b8048a8ebe 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -155,6 +155,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "version": 1, "options": {}, "minor_version": 1, + "subentries": (), } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 3094fcef24b..2d6f6687d04 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -33,6 +33,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 5fc2a114b2f..3a54c3fb9a2 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -62,6 +62,7 @@ "active_preset": "home", "available_schedules": ["standaard", "off"], "climate_mode": "auto", + "control_state": "heating", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json index 2cb439950af..9275b82cde9 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/all_data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/all_data.json @@ -37,6 +37,7 @@ "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", "climate_mode": "heat", + "control_state": "heating", "dev_class": "thermostat", "firmware": "2017-03-13T11:54:58+01:00", "hardware": "6539-1301-500", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 9c40e50278b..af6d4b83380 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -176,8 +176,8 @@ "Weekschema", "off" ], - "climate_mode": "cool", - "control_state": "auto", + "climate_mode": "auto", + "control_state": "cooling", "dev_class": "climate", "model": "ThermoZone", "name": "Bathroom", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index fab2cea5fdc..bb24faeebfa 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -176,7 +176,7 @@ "off" ], "climate_mode": "auto", - "control_state": "off", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Bathroom", diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json index 4516ce2c2d0..1a3ef66c147 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -3,7 +3,6 @@ "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", "climate_mode": "off", - "control_state": "off", "dev_class": "climate", "model": "ThermoZone", "name": "Slaapkamer", @@ -26,7 +25,7 @@ "13228dab8ce04617af318a2888b3c548": { "active_preset": "home", "climate_mode": "heat", - "control_state": "off", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Woonkamer", @@ -238,7 +237,7 @@ "d27aede973b54be484f6842d1b2802ad": { "active_preset": "home", "climate_mode": "heat", - "control_state": "off", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Kinderkamer", @@ -285,7 +284,7 @@ "d58fec52899f4f1c92e4f8fad6d8c48c": { "active_preset": "home", "climate_mode": "heat", - "control_state": "off", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Logeerkamer", diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json index 67e8c235cc3..8da184a7a3e 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json @@ -32,6 +32,7 @@ "off" ], "climate_mode": "auto", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Badkamer", @@ -66,6 +67,7 @@ "off" ], "climate_mode": "heat", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Bios", @@ -112,6 +114,7 @@ "446ac08dd04d4eff8ac57489757b7314": { "active_preset": "no_frost", "climate_mode": "heat", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Garage", @@ -258,6 +261,7 @@ "off" ], "climate_mode": "auto", + "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Jessie", @@ -402,6 +406,7 @@ "off" ], "climate_mode": "auto", + "control_state": "heating", "dev_class": "climate", "model": "ThermoZone", "name": "Woonkamer", @@ -577,7 +582,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "item_count": 364, + "item_count": 369, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 74f20379d68..eaa42facf10 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -62,6 +62,7 @@ "active_preset": "home", "available_schedules": ["standaard", "off"], "climate_mode": "auto", + "control_state": "cooling", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 3b1e9bf8cac..52645b0f317 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -62,6 +62,7 @@ "active_preset": "home", "available_schedules": ["standaard", "off"], "climate_mode": "auto", + "control_state": "idle", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index bf7d4260a32..806c92fe7cb 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -34,6 +34,7 @@ 'off', ]), 'climate_mode': 'auto', + 'control_state': 'idle', 'dev_class': 'climate', 'model': 'ThermoZone', 'name': 'Badkamer', @@ -75,6 +76,7 @@ 'off', ]), 'climate_mode': 'heat', + 'control_state': 'idle', 'dev_class': 'climate', 'model': 'ThermoZone', 'name': 'Bios', @@ -131,6 +133,7 @@ '446ac08dd04d4eff8ac57489757b7314': dict({ 'active_preset': 'no_frost', 'climate_mode': 'heat', + 'control_state': 'idle', 'dev_class': 'climate', 'model': 'ThermoZone', 'name': 'Garage', @@ -286,6 +289,7 @@ 'off', ]), 'climate_mode': 'auto', + 'control_state': 'idle', 'dev_class': 'climate', 'model': 'ThermoZone', 'name': 'Jessie', @@ -440,6 +444,7 @@ 'off', ]), 'climate_mode': 'auto', + 'control_state': 'heating', 'dev_class': 'climate', 'model': 'ThermoZone', 'name': 'Woonkamer', @@ -625,7 +630,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 364, + 'item_count': 369, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c0c1c00c68d..8368af8e5cc 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -8,12 +8,31 @@ from plugwise.exceptions import PlugwiseError import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_HOME, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -31,37 +50,33 @@ async def test_adam_climate_entity_attributes( state = hass.states.get("climate.woonkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] - # hvac_action is not asserted as the fixture is not in line with recent firmware functionality - - assert "preset_modes" in state.attributes - assert "no_frost" in state.attributes["preset_modes"] - assert "home" in state.attributes["preset_modes"] - - assert state.attributes["current_temperature"] == 20.9 - assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 - assert state.attributes["temperature"] == 21.5 - assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 35.0 - assert state.attributes["target_temp_step"] == 0.1 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] + assert ATTR_PRESET_MODES in state.attributes + assert "no_frost" in state.attributes[ATTR_PRESET_MODES] + assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.9 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 17 + assert state.attributes[ATTR_TEMPERATURE] == 21.5 + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 35.0 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 state = hass.states.get("climate.jessie") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] - # hvac_action is not asserted as the fixture is not in line with recent firmware functionality - - assert "preset_modes" in state.attributes - assert "no_frost" in state.attributes["preset_modes"] - assert "home" in state.attributes["preset_modes"] - - assert state.attributes["current_temperature"] == 17.2 - assert state.attributes["preset_mode"] == "asleep" - assert state.attributes["temperature"] == 15.0 - assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 35.0 - assert state.attributes["target_temp_step"] == 0.1 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] + assert ATTR_PRESET_MODES in state.attributes + assert "no_frost" in state.attributes[ATTR_PRESET_MODES] + assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] + assert state.attributes[ATTR_PRESET_MODE] == "asleep" + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 17.2 + assert state.attributes[ATTR_TEMPERATURE] == 15.0 + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 35.0 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 async def test_adam_2_climate_entity_attributes( @@ -71,8 +86,8 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "preheating" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.PREHEATING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, @@ -81,8 +96,8 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.bathroom") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "idle" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, @@ -99,8 +114,8 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, @@ -109,7 +124,9 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "heating" ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = "heating" + data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( + HVACAction.HEATING + ) data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = False @@ -124,8 +141,8 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, @@ -135,7 +152,9 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "cooling" ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = "cooling" + data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( + HVACAction.COOLING + ) data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = True @@ -150,8 +169,8 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, @@ -168,7 +187,7 @@ async def test_adam_climate_adjust_negative_testing( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.woonkamer", "temperature": 25}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, blocking=True, ) @@ -180,7 +199,7 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.woonkamer", "temperature": 25}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, blocking=True, ) assert mock_smile_adam.set_temperature.call_count == 1 @@ -192,9 +211,9 @@ async def test_adam_climate_entity_climate_changes( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - "entity_id": "climate.woonkamer", - "hvac_mode": "heat", - "temperature": 25, + ATTR_ENTITY_ID: "climate.woonkamer", + ATTR_HVAC_MODE: HVACMode.HEAT, + ATTR_TEMPERATURE: 25, }, blocking=True, ) @@ -203,43 +222,43 @@ async def test_adam_climate_entity_climate_changes( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} ) - with pytest.raises(ServiceValidationError): + with pytest.raises(ServiceValidationError, match="Accepted range"): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.woonkamer", "temperature": 150}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 150}, blocking=True, ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {"entity_id": "climate.woonkamer", "preset_mode": "away"}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, ) assert mock_smile_adam.set_preset.call_count == 1 mock_smile_adam.set_preset.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "away" + "c50f167537524366a5af7aa3942feb1e", PRESET_AWAY ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.woonkamer", "hvac_mode": "heat"}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) assert mock_smile_adam.set_schedule_state.call_count == 2 mock_smile_adam.set_schedule_state.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "off" + "c50f167537524366a5af7aa3942feb1e", HVACMode.OFF ) - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError, match="valid modes are"): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.jessie", - "hvac_mode": "dry", + ATTR_ENTITY_ID: "climate.jessie", + ATTR_HVAC_MODE: HVACMode.DRY, }, blocking=True, ) @@ -258,8 +277,8 @@ async def test_adam_climate_off_mode_change( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.slaapkamer", - "hvac_mode": "heat", + ATTR_ENTITY_ID: "climate.slaapkamer", + ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, ) @@ -274,8 +293,8 @@ async def test_adam_climate_off_mode_change( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.kinderkamer", - "hvac_mode": "off", + ATTR_ENTITY_ID: "climate.kinderkamer", + ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, ) @@ -290,8 +309,8 @@ async def test_adam_climate_off_mode_change( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.logeerkamer", - "hvac_mode": "heat", + ATTR_ENTITY_ID: "climate.logeerkamer", + ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, ) @@ -308,20 +327,20 @@ async def test_anna_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT_COOL] + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT_COOL] - assert "no_frost" in state.attributes["preset_modes"] - assert "home" in state.attributes["preset_modes"] + assert "no_frost" in state.attributes[ATTR_PRESET_MODES] + assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes["current_temperature"] == 19.3 - assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 18 - assert state.attributes["target_temp_high"] == 30 - assert state.attributes["target_temp_low"] == 20.5 - assert state.attributes["min_temp"] == 4 - assert state.attributes["max_temp"] == 30 - assert state.attributes["target_temp_step"] == 0.1 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 19.3 + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 + assert state.attributes[ATTR_MIN_TEMP] == 4 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 async def test_anna_2_climate_entity_attributes( @@ -333,14 +352,14 @@ async def test_anna_2_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.AUTO, HVACMode.HEAT_COOL, ] - assert state.attributes["supported_features"] == 18 - assert state.attributes["target_temp_high"] == 30 - assert state.attributes["target_temp_low"] == 20.5 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 async def test_anna_3_climate_entity_attributes( @@ -352,8 +371,8 @@ async def test_anna_3_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "idle" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.AUTO, HVACMode.HEAT_COOL, ] @@ -369,7 +388,11 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.anna", "target_temp_high": 30, "target_temp_low": 20}, + { + ATTR_ENTITY_ID: "climate.anna", + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 20, + }, blocking=True, ) assert mock_smile_anna.set_temperature.call_count == 1 @@ -381,18 +404,18 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {"entity_id": "climate.anna", "preset_mode": "away"}, + {ATTR_ENTITY_ID: "climate.anna", ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, ) assert mock_smile_anna.set_preset.call_count == 1 mock_smile_anna.set_preset.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "away" + "c784ee9fdab44e1395b8dee7d7a497d5", PRESET_AWAY ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.anna", "hvac_mode": "auto"}, + {ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO}, blocking=True, ) # hvac_mode is already auto so not called. @@ -401,12 +424,12 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.anna", "hvac_mode": "heat_cool"}, + {ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, blocking=True, ) assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "off" + "c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF ) data = mock_smile_anna.async_update.return_value @@ -418,4 +441,4 @@ async def test_anna_climate_entity_climate_changes( state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT - assert state.attributes["hvac_modes"] == [HVACMode.HEAT_COOL] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index baf6edea9c7..9e1e29f4a48 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -35,6 +35,7 @@ TEST_PASSWORD = "test_password" TEST_PORT = 81 TEST_USERNAME = "smile" TEST_USERNAME2 = "stretch" +MOCK_SMILE_ID = "smile12345" TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), @@ -128,6 +129,8 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_smile_config_flow.connect.mock_calls) == 1 + assert result2["result"].unique_id == MOCK_SMILE_ID + @pytest.mark.parametrize( ("discovery", "username"), @@ -172,6 +175,8 @@ async def test_zeroconf_flow( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_smile_config_flow.connect.mock_calls) == 1 + assert result2["result"].unique_id == MOCK_SMILE_ID + async def test_zeroconf_flow_stretch( hass: HomeAssistant, @@ -311,6 +316,69 @@ async def test_flow_errors( assert len(mock_smile_config_flow.connect.mock_calls) == 2 +async def test_user_abort_existing_anna( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_smile_config_flow: MagicMock, +) -> None: + """Test the full user configuration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=MOCK_SMILE_ID, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: TEST_HOST, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + + +async def test_zeroconf_abort_existing_anna( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_smile_config_flow: MagicMock, +) -> None: + """Test the full user configuration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=TEST_HOSTNAME, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DISCOVERY_ANNA, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + async def test_zeroconf_abort_anna_with_existing_config_entries( hass: HomeAssistant, mock_smile_adam: MagicMock, diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 3b9881c9e3d..014003d29d0 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed @@ -78,7 +77,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED @@ -119,7 +117,7 @@ async def test_device_in_dr( ) -> None: """Test Gateway device registry data.""" mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() device_entry = device_registry.async_get_device( @@ -238,7 +236,7 @@ async def test_update_device( data = mock_smile_adam_2.async_update.return_value mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert ( diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index e10a7caa9e9..fdceb042669 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -9,6 +11,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry @@ -101,3 +104,19 @@ async def test_adam_temperature_offset_change( mock_smile_adam.set_number.assert_called_with( "6a3bf693d05e48e0b460c815a4fdd09d", "temperature_offset", 1.0 ) + + +async def test_adam_temperature_offset_out_of_bounds_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of the temperature_offset number beyond limits.""" + with pytest.raises(ServiceValidationError, match="valid range"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.zone_thermostat_jessie_temperature_offset", + ATTR_VALUE: 3.0, + }, + blocking=True, + ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 0fab41cdbae..8891a88bb91 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -9,6 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry @@ -65,8 +68,8 @@ async def test_adam_select_regulation_mode( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - "entity_id": "select.adam_regulation_mode", - "option": "heating", + ATTR_ENTITY_ID: "select.adam_regulation_mode", + ATTR_OPTION: "heating", }, blocking=True, ) @@ -86,3 +89,20 @@ async def test_legacy_anna_select_entities( ) -> None: """Test not creating a select-entity for a legacy Anna without a thermostat-schedule.""" assert not hass.states.get("select.anna_thermostat_schedule") + + +async def test_adam_select_unavailable_regulation_mode( + hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test a regulation_mode non-available preset.""" + + with pytest.raises(ServiceValidationError, match="valid options"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.anna_thermostat_schedule", + ATTR_OPTION: "freezing", + }, + blocking=True, + ) diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 0745adb786a..f10f3f00933 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -135,6 +137,7 @@ async def test_p1_dsmr_sensor_entities( assert not state +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_3ph_dsmr_sensor_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -154,21 +157,23 @@ async def test_p1_3ph_dsmr_sensor_entities( assert state assert int(state.state) == 2080 - entity_id = "sensor.p1_voltage_phase_one" - state = hass.states.get(entity_id) - assert not state - - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done() - - await hass.config_entries.async_reload(init_integration.entry_id) - await hass.async_block_till_done() - + # Default disabled sensor test state = hass.states.get("sensor.p1_voltage_phase_one") assert state assert float(state.state) == 233.2 +async def test_p1_3ph_dsmr_sensor_disabled_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_smile_p1_2: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test disabled power related sensor entities intent.""" + state = hass.states.get("sensor.p1_voltage_phase_one") + assert not state + + async def test_stretch_sensor_entities( hass: HomeAssistant, mock_stretch: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index d9a4792ddb1..fa8a8a434e7 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -44,7 +45,7 @@ async def test_adam_climate_switch_negative_testing( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.cv_pomp_relay"}, + {ATTR_ENTITY_ID: "switch.cv_pomp_relay"}, blocking=True, ) @@ -57,7 +58,7 @@ async def test_adam_climate_switch_negative_testing( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.fibaro_hc2_relay"}, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, blocking=True, ) @@ -74,7 +75,7 @@ async def test_adam_climate_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.cv_pomp_relay"}, + {ATTR_ENTITY_ID: "switch.cv_pomp_relay"}, blocking=True, ) @@ -86,7 +87,7 @@ async def test_adam_climate_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, - {"entity_id": "switch.fibaro_hc2_relay"}, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, blocking=True, ) @@ -98,7 +99,7 @@ async def test_adam_climate_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.fibaro_hc2_relay"}, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, blocking=True, ) @@ -128,7 +129,7 @@ async def test_stretch_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.koelkast_92c4a_relay"}, + {ATTR_ENTITY_ID: "switch.koelkast_92c4a_relay"}, blocking=True, ) assert mock_stretch.set_switch_state.call_count == 1 @@ -139,7 +140,7 @@ async def test_stretch_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, - {"entity_id": "switch.droger_52559_relay"}, + {ATTR_ENTITY_ID: "switch.droger_52559_relay"}, blocking=True, ) assert mock_stretch.set_switch_state.call_count == 2 @@ -150,7 +151,7 @@ async def test_stretch_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.droger_52559_relay"}, + {ATTR_ENTITY_ID: "switch.droger_52559_relay"}, blocking=True, ) assert mock_stretch.set_switch_state.call_count == 3 diff --git a/tests/components/powerfox/__init__.py b/tests/components/powerfox/__init__.py new file mode 100644 index 00000000000..d24e52eba9b --- /dev/null +++ b/tests/components/powerfox/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Powerfox integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_DIRECT_HOST = "1.1.1.1" + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/powerfox/conftest.py b/tests/components/powerfox/conftest.py new file mode 100644 index 00000000000..14ccc5996e5 --- /dev/null +++ b/tests/components/powerfox/conftest.py @@ -0,0 +1,87 @@ +"""Common fixtures for the Powerfox tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from powerfox import Device, DeviceType, PowerMeter, WaterMeter +import pytest + +from homeassistant.components.powerfox.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.powerfox.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_powerfox_client() -> Generator[AsyncMock]: + """Mock a Powerfox client.""" + with ( + patch( + "homeassistant.components.powerfox.Powerfox", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.powerfox.config_flow.Powerfox", + new=mock_client, + ), + ): + client = mock_client.return_value + client.all_devices.return_value = [ + Device( + id="9x9x1f12xx3x", + date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC), + main_device=True, + bidirectional=True, + type=DeviceType.POWER_METER, + name="Poweropti", + ), + Device( + id="9x9x1f12xx4x", + date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC), + main_device=False, + bidirectional=False, + type=DeviceType.COLD_WATER_METER, + name="Wateropti", + ), + ] + client.device.side_effect = [ + PowerMeter( + outdated=False, + timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC), + power=111, + energy_usage=1111.111, + energy_return=111.111, + energy_usage_high_tariff=111.111, + energy_usage_low_tariff=111.111, + ), + WaterMeter( + outdated=False, + timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC), + cold_water=1111.111, + warm_water=0.0, + ), + ] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Powerfox config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Powerfox", + data={ + CONF_EMAIL: "test@powerfox.test", + CONF_PASSWORD: "test-password", + }, + ) diff --git a/tests/components/powerfox/snapshots/test_diagnostics.ambr b/tests/components/powerfox/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..781e7b8c0d5 --- /dev/null +++ b/tests/components/powerfox/snapshots/test_diagnostics.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'devices': list([ + dict({ + 'power_meter': dict({ + 'energy_return': 111.111, + 'energy_usage': 1111.111, + 'energy_usage_high_tariff': 111.111, + 'energy_usage_low_tariff': 111.111, + 'outdated': False, + 'power': 111, + 'timestamp': '2024-11-26 10:48:51', + }), + }), + dict({ + 'water_meter': dict({ + 'cold_water': 1111.111, + 'outdated': False, + 'timestamp': '2024-11-26 10:48:51', + 'warm_water': 0.0, + }), + }), + ]), + }) +# --- diff --git a/tests/components/powerfox/snapshots/test_sensor.ambr b/tests/components/powerfox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dda162d4eeb --- /dev/null +++ b/tests/components/powerfox/snapshots/test_sensor.ambr @@ -0,0 +1,358 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.poweropti_energy_return-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_return', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy return', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_return', + 'unique_id': '9x9x1f12xx3x_energy_return', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_return-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy return', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_return', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy usage', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage', + 'unique_id': '9x9x1f12xx3x_energy_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_high_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_usage_high_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy usage high tariff', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_high_tariff', + 'unique_id': '9x9x1f12xx3x_energy_usage_high_tariff', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_high_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy usage high tariff', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_usage_high_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_low_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_usage_low_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy usage low tariff', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_low_tariff', + 'unique_id': '9x9x1f12xx3x_energy_usage_low_tariff', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_low_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy usage low tariff', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_usage_low_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '9x9x1f12xx3x_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Poweropti Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_all_sensors[sensor.wateropti_cold_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wateropti_cold_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cold water', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cold_water', + 'unique_id': '9x9x1f12xx4x_cold_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.wateropti_cold_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Wateropti Cold water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wateropti_cold_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.wateropti_warm_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wateropti_warm_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Warm water', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'warm_water', + 'unique_id': '9x9x1f12xx4x_warm_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.wateropti_warm_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Wateropti Warm water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wateropti_warm_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/powerfox/test_config_flow.py b/tests/components/powerfox/test_config_flow.py new file mode 100644 index 00000000000..a38f316faf3 --- /dev/null +++ b/tests/components/powerfox/test_config_flow.py @@ -0,0 +1,323 @@ +"""Test the Powerfox config flow.""" + +from unittest.mock import AsyncMock, patch + +from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError +import pytest + +from homeassistant.components import zeroconf +from homeassistant.components.powerfox.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_DIRECT_HOST + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + ip_address=MOCK_DIRECT_HOST, + ip_addresses=[MOCK_DIRECT_HOST], + hostname="powerfox.local", + name="Powerfox", + port=443, + type="_http._tcp", + properties={}, +) + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "test@powerfox.test" + assert result.get("data") == { + CONF_EMAIL: "test@powerfox.test", + CONF_PASSWORD: "test-password", + } + assert len(mock_powerfox_client.all_devices.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "test@powerfox.test" + assert result.get("data") == { + CONF_EMAIL: "test@powerfox.test", + CONF_PASSWORD: "test-password", + } + assert len(mock_powerfox_client.all_devices.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_powerfox_client: AsyncMock, +) -> None: + """Test abort when setting up duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_duplicate_entry_reconfiguration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_powerfox_client: AsyncMock, +) -> None: + """Test abort when setting up duplicate entry on reconfiguration.""" + # Add two config entries + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_EMAIL: "new@powerfox.test", CONF_PASSWORD: "new-password"}, + ) + mock_config_entry_2.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 2 + + # Reconfigure the second entry + result = await mock_config_entry_2.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions during config flow.""" + mock_powerfox_client.all_devices.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_step_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test re-authentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + with patch( + "homeassistant.components.powerfox.config_flow.Powerfox", + autospec=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_step_reauth_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions during re-authentication flow.""" + mock_powerfox_client.all_devices.side_effect = exception + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration of existing entry.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new-email@powerfox.test", + CONF_PASSWORD: "new-password", + }, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_EMAIL] == "new-email@powerfox.test" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_reconfigure_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test exceptions during reconfiguration flow.""" + mock_powerfox_client.all_devices.side_effect = exception + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new-email@powerfox.test", + CONF_PASSWORD: "new-password", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new-email@powerfox.test", + CONF_PASSWORD: "new-password", + }, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_EMAIL] == "new-email@powerfox.test" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/powerfox/test_diagnostics.py b/tests/components/powerfox/test_diagnostics.py new file mode 100644 index 00000000000..7dc2c3c7263 --- /dev/null +++ b/tests/components/powerfox/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test for PowerFox diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the PowerFox entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot diff --git a/tests/components/powerfox/test_init.py b/tests/components/powerfox/test_init.py new file mode 100644 index 00000000000..1ad60babc04 --- /dev/null +++ b/tests/components/powerfox/test_init.py @@ -0,0 +1,62 @@ +"""Test the Powerfox init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Powerfox configuration entry not ready.""" + mock_powerfox_client.all_devices.side_effect = PowerfoxConnectionError + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_exception( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + mock_config_entry.add_to_hass(hass) + mock_powerfox_client.device.side_effect = PowerfoxAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/powerfox/test_sensor.py b/tests/components/powerfox/test_sensor.py new file mode 100644 index 00000000000..547d8de202c --- /dev/null +++ b/tests/components/powerfox/test_sensor.py @@ -0,0 +1,53 @@ +"""Test the sensors provided by the Powerfox integration.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from powerfox import PowerfoxConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_sensors( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Powerfox sensors.""" + with patch("homeassistant.components.powerfox.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_update_failed( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities become unavailable after failed update.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("sensor.poweropti_energy_usage").state is not None + + mock_powerfox_client.device.side_effect = PowerfoxConnectionError + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.poweropti_energy_usage").state == STATE_UNAVAILABLE diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 37940df437b..84314b7b22c 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -211,9 +211,10 @@ async def test_dump_log_object( assert hass.services.has_service(DOMAIN, SERVICE_DUMP_LOG_OBJECTS) - await hass.services.async_call( - DOMAIN, SERVICE_DUMP_LOG_OBJECTS, {CONF_TYPE: "DumpLogDummy"}, blocking=True - ) + with patch("objgraph.by_type", return_value=[obj1, obj2]): + await hass.services.async_call( + DOMAIN, SERVICE_DUMP_LOG_OBJECTS, {CONF_TYPE: "DumpLogDummy"}, blocking=True + ) assert "" in caplog.text assert "Failed to serialize" in caplog.text diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 3d9673ffd90..42ec74710f9 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -102,6 +102,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'home', 'unique_id': 'proximity_home', 'version': 1, diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index d14f367b2bd..24d45fee5b9 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -52,6 +52,7 @@ MOCK_FLOW_RESULT = { "title": "test_ps4", "data": MOCK_DATA, "options": {}, + "subentries": (), } MOCK_ENTRY_ID = "SomeID" diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index 3d6776dd12e..1809b16bd75 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -73,7 +73,7 @@ def config_entry_options_fixture() -> dict[str, Any]: @pytest.fixture(name="get_sensors_response", scope="package") def get_sensors_response_fixture() -> GetSensorsResponse: """Define a fixture to mock an aiopurpleair GetSensorsResponse object.""" - return GetSensorsResponse.parse_raw( + return GetSensorsResponse.model_validate_json( load_fixture("get_sensors_response.json", "purpleair") ) diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index ae4b28567be..6271a63d652 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -38,6 +38,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "fields": [ diff --git a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr index e131bf3d952..abf8e380916 100644 --- a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr +++ b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -84,6 +86,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr index acd5fd165b4..681805996f1 100644 --- a/tests/components/rainmachine/snapshots/test_diagnostics.ambr +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -1144,6 +1144,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, @@ -2275,6 +2277,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index 24c690bcb37..a57e289ec04 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -34,6 +34,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": [ { diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 60168f5e6ef..fbb0991c960 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -428,14 +428,6 @@ def get_schema_module_path(schema_version_postfix: str) -> str: return f"tests.components.recorder.db_schema_{schema_version_postfix}" -@dataclass(slots=True) -class MockMigrationTask(migration.MigrationTask): - """Mock migration task which does nothing.""" - - def run(self, instance: Recorder) -> None: - """Run migration task.""" - - @contextmanager def old_db_schema(schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" @@ -453,7 +445,6 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch.object(core, "StateAttributes", old_db_schema.StateAttributes), - patch.object(migration.EntityIDMigration, "task", MockMigrationTask), patch( CREATE_ENGINE_TARGET, new=partial( diff --git a/tests/components/recorder/table_managers/test_recorder_runs.py b/tests/components/recorder/table_managers/test_recorder_runs.py index e79def01bad..41f3a8fef4d 100644 --- a/tests/components/recorder/table_managers/test_recorder_runs.py +++ b/tests/components/recorder/table_managers/test_recorder_runs.py @@ -21,11 +21,6 @@ async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None two_days_ago = now - timedelta(days=2) one_day_ago = now - timedelta(days=1) - # Test that the first run falls back to the current run - assert process_timestamp( - instance.recorder_runs_manager.first.start - ) == process_timestamp(instance.recorder_runs_manager.current.start) - with instance.get_session() as session: session.add(RecorderRuns(start=three_days_ago, created=three_days_ago)) session.add(RecorderRuns(start=two_days_ago, created=two_days_ago)) @@ -34,7 +29,32 @@ async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None instance.recorder_runs_manager.load_from_db(session) assert ( - process_timestamp(instance.recorder_runs_manager.first.start) == three_days_ago + process_timestamp( + instance.recorder_runs_manager.get( + three_days_ago + timedelta(microseconds=1) + ).start + ) + == three_days_ago + ) + assert ( + process_timestamp( + instance.recorder_runs_manager.get( + two_days_ago + timedelta(microseconds=1) + ).start + ) + == two_days_ago + ) + assert ( + process_timestamp( + instance.recorder_runs_manager.get( + one_day_ago + timedelta(microseconds=1) + ).start + ) + == one_day_ago + ) + assert ( + process_timestamp(instance.recorder_runs_manager.get(now).start) + == instance.recorder_runs_manager.recording_start ) diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 3ee6edd8e1e..666626ff688 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -38,6 +38,17 @@ async def mock_recorder_before_hass( """Set up recorder.""" +@pytest.fixture +def disable_states_meta_manager(): + """Disable the states meta manager.""" + with patch.object( + recorder.table_managers.states_meta.StatesMetaManager, + "active", + False, + ): + yield + + @pytest.fixture(autouse=True) def db_schema_32(): """Fixture to initialize the db with the old schema 32.""" @@ -46,7 +57,9 @@ def db_schema_32(): @pytest.fixture(autouse=True) -def setup_recorder(db_schema_32, recorder_mock: Recorder) -> recorder.Recorder: +def setup_recorder( + db_schema_32, disable_states_meta_manager, recorder_mock: Recorder +) -> recorder.Recorder: """Set up recorder.""" diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 6ef97f3bbd1..e42cd22e952 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -44,7 +44,6 @@ import homeassistant.util.dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( - MockMigrationTask, async_attach_db_engine, async_recorder_block_till_done, async_wait_recording_done, @@ -114,7 +113,6 @@ def db_schema_32(): patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch.object(core, "StateAttributes", old_db_schema.StateAttributes), - patch.object(migration.EntityIDMigration, "task", MockMigrationTask), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): yield @@ -826,13 +824,13 @@ async def test_finish_migrate_states_context_ids( await hass.async_block_till_done() +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_event_type_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceGenerator, ) -> None: """Test we can migrate event_types to the EventTypes table.""" - await async_wait_recording_done(hass) importlib.import_module(SCHEMA_MODULE_32) old_db_schema = sys.modules[SCHEMA_MODULE_32] @@ -858,14 +856,24 @@ async def test_migrate_event_type_ids( ) ) - await recorder_mock.async_add_executor_job(_insert_events) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventTypeIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EventTypeIDMigration(None, None) - recorder_mock.queue_task(migrator.task(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -896,34 +904,51 @@ async def test_migrate_event_type_ids( ) return result - events_by_type = await recorder_mock.async_add_executor_job(_fetch_migrated_events) - assert len(events_by_type["event_type_one"]) == 2 - assert len(events_by_type["event_type_two"]) == 1 - def _get_many(): with session_scope(hass=hass, read_only=True) as session: - return recorder_mock.event_type_manager.get_many( + return instance.event_type_manager.get_many( ("event_type_one", "event_type_two"), session ) - mapped = await recorder_mock.async_add_executor_job(_get_many) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + mapped = await instance.async_add_executor_job(_get_many) + migration_changes = await instance.async_add_executor_job( + _get_migration_id, hass + ) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type["event_type_two"]) == 1 + assert mapped["event_type_one"] is not None assert mapped["event_type_two"] is not None - migration_changes = await recorder_mock.async_add_executor_job( - _get_migration_id, hass - ) assert ( migration_changes[migration.EventTypeIDMigration.migration_id] == migration.EventTypeIDMigration.migration_version ) +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") -async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) -> None: +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_migrate_entity_ids( + async_test_recorder: RecorderInstanceGenerator, +) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" - await async_wait_recording_done(hass) importlib.import_module(SCHEMA_MODULE_32) old_db_schema = sys.modules[SCHEMA_MODULE_32] @@ -949,14 +974,24 @@ async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) ) ) - await recorder_mock.async_add_executor_job(_insert_states) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EntityIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_states) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EntityIDMigration(old_db_schema.SCHEMA_VERSION, {}) - recorder_mock.queue_task(migration.CommitBeforeMigrationTask(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -982,28 +1017,43 @@ async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) ) return result - states_by_entity_id = await recorder_mock.async_add_executor_job( - _fetch_migrated_states - ) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_entity_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) + migration_changes = await instance.async_add_executor_job( + _get_migration_id, hass + ) + + await hass.async_stop() + await hass.async_block_till_done() + assert len(states_by_entity_id["sensor.two"]) == 2 assert len(states_by_entity_id["sensor.one"]) == 1 - migration_changes = await recorder_mock.async_add_executor_job( - _get_migration_id, hass - ) assert ( migration_changes[migration.EntityIDMigration.migration_id] == migration.EntityIDMigration.migration_version ) +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_post_migrate_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceGenerator, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" - await async_wait_recording_done(hass) importlib.import_module(SCHEMA_MODULE_32) old_db_schema = sys.modules[SCHEMA_MODULE_32] @@ -1029,14 +1079,25 @@ async def test_post_migrate_entity_ids( ) ) - await recorder_mock.async_add_executor_job(_insert_events) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EntityIDMigration, "migrate_data"), + patch.object(migration.EntityIDPostMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_events) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EntityIDPostMigration(None, None) - recorder_mock.queue_task(migrator.task(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -1047,19 +1108,34 @@ async def test_post_migrate_entity_ids( assert len(states) == 3 return {state.state: state.entity_id for state in states} - states_by_state = await recorder_mock.async_add_executor_job(_fetch_migrated_states) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_state = await instance.async_add_executor_job(_fetch_migrated_states) + + await hass.async_stop() + await hass.async_block_till_done() + assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceGenerator, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" - await async_wait_recording_done(hass) importlib.import_module(SCHEMA_MODULE_32) old_db_schema = sys.modules[SCHEMA_MODULE_32] @@ -1088,14 +1164,24 @@ async def test_migrate_null_entity_ids( ), ) - await recorder_mock.async_add_executor_job(_insert_states) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EntityIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_states) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EntityIDMigration(old_db_schema.SCHEMA_VERSION, {}) - recorder_mock.queue_task(migration.CommitBeforeMigrationTask(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -1121,30 +1207,45 @@ async def test_migrate_null_entity_ids( ) return result - states_by_entity_id = await recorder_mock.async_add_executor_job( - _fetch_migrated_states - ) - assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 - assert len(states_by_entity_id["sensor.one"]) == 2 - def _get_migration_id(): with session_scope(hass=hass, read_only=True) as session: return dict(execute_stmt_lambda_element(session, get_migration_changes())) - migration_changes = await recorder_mock.async_add_executor_job(_get_migration_id) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_entity_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) + migration_changes = await instance.async_add_executor_job(_get_migration_id) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + assert ( migration_changes[migration.EntityIDMigration.migration_id] == migration.EntityIDMigration.migration_version ) +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_event_type_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceGenerator, ) -> None: """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" - await async_wait_recording_done(hass) importlib.import_module(SCHEMA_MODULE_32) old_db_schema = sys.modules[SCHEMA_MODULE_32] @@ -1173,14 +1274,24 @@ async def test_migrate_null_event_type_ids( ), ) - await recorder_mock.async_add_executor_job(_insert_events) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventTypeIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_events) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EventTypeIDMigration(None, None) - recorder_mock.queue_task(migrator.task(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -1211,15 +1322,29 @@ async def test_migrate_null_event_type_ids( ) return result - events_by_type = await recorder_mock.async_add_executor_job(_fetch_migrated_events) - assert len(events_by_type["event_type_one"]) == 2 - assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 - def _get_migration_id(): with session_scope(hass=hass, read_only=True) as session: return dict(execute_stmt_lambda_element(session, get_migration_changes())) - migration_changes = await recorder_mock.async_add_executor_job(_get_migration_id) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + migration_changes = await instance.async_add_executor_job(_get_migration_id) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 assert ( migration_changes[migration.EventTypeIDMigration.migration_id] == migration.EventTypeIDMigration.migration_version diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 93fa16b8364..7a333b0a2f5 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -19,11 +19,7 @@ from homeassistant.components.recorder.util import ( from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .common import ( - MockMigrationTask, - async_recorder_block_till_done, - async_wait_recording_done, -) +from .common import async_recorder_block_till_done, async_wait_recording_done from tests.common import async_test_home_assistant from tests.typing import RecorderInstanceGenerator @@ -102,7 +98,6 @@ async def test_migration_changes_prevent_trying_to_migrate_again( patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch.object(core, "StateAttributes", old_db_schema.StateAttributes), - patch.object(migration.EntityIDMigration, "task", MockMigrationTask), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 9078b2e861c..b2894883ff2 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -325,6 +325,7 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( state="off", attributes='{"shared":true}', last_updated_ts=now.timestamp(), + last_reported_ts=now.timestamp(), last_changed_ts=(now - timedelta(seconds=60)).timestamp(), ) lstate = LazyState( @@ -339,6 +340,7 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( } assert lstate.last_updated.timestamp() == row.last_updated_ts assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.last_reported.timestamp() == row.last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -346,6 +348,9 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } + assert lstate.last_changed_timestamp == row.last_changed_ts + assert lstate.last_updated_timestamp == row.last_updated_ts + assert lstate.last_reported_timestamp == row.last_updated_ts async def test_lazy_state_handles_same_last_updated_and_last_changed( @@ -359,6 +364,7 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( attributes='{"shared":true}', last_updated_ts=now.timestamp(), last_changed_ts=now.timestamp(), + last_reported_ts=None, ) lstate = LazyState( row, {}, None, row.entity_id, row.state, row.last_updated_ts, False @@ -372,6 +378,7 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( } assert lstate.last_updated.timestamp() == row.last_updated_ts assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.last_reported.timestamp() == row.last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -379,3 +386,37 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } + assert lstate.last_changed_timestamp == row.last_changed_ts + assert lstate.last_updated_timestamp == row.last_updated_ts + assert lstate.last_reported_timestamp == row.last_updated_ts + + +async def test_lazy_state_handles_different_last_reported( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the LazyState handles last_reported different from last_updated.""" + now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) + row = PropertyMock( + entity_id="sensor.valid", + state="off", + attributes='{"shared":true}', + last_updated_ts=(now - timedelta(seconds=60)).timestamp(), + last_reported_ts=now.timestamp(), + last_changed_ts=(now - timedelta(seconds=60)).timestamp(), + ) + lstate = LazyState( + row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + ) + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:03:01.000323+00:00", + "last_updated": "2021-06-12T03:03:01.000323+00:00", + "state": "off", + } + assert lstate.last_updated.timestamp() == row.last_updated_ts + assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.last_reported.timestamp() == row.last_reported_ts + assert lstate.last_changed_timestamp == row.last_changed_ts + assert lstate.last_updated_timestamp == row.last_updated_ts + assert lstate.last_reported_timestamp == row.last_reported_ts diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 076f6ae8bab..ea764b14401 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -112,9 +112,6 @@ async def test_purge_big_database(hass: HomeAssistant, recorder_mock: Recorder) async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> None: """Test deleting old states.""" - assert recorder_mock.states_manager.oldest_ts is None - oldest_ts = recorder_mock.states_manager.oldest_ts - await _add_test_states(hass) # make sure we start with 6 states @@ -130,10 +127,6 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 - assert recorder_mock.states_manager.oldest_ts != oldest_ts - assert recorder_mock.states_manager.oldest_ts == states[0].last_updated_ts - oldest_ts = recorder_mock.states_manager.oldest_ts - assert "test.recorder2" in recorder_mock.states_manager._last_committed_id purge_before = dt_util.utcnow() - timedelta(days=4) @@ -147,8 +140,6 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> repack=False, ) assert not finished - # states_manager.oldest_ts is not updated until after the purge is complete - assert recorder_mock.states_manager.oldest_ts == oldest_ts with session_scope(hass=hass) as session: states = session.query(States) @@ -171,8 +162,6 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> finished = purge_old_data(recorder_mock, purge_before, repack=False) assert finished - # states_manager.oldest_ts should now be updated - assert recorder_mock.states_manager.oldest_ts != oldest_ts with session_scope(hass=hass) as session: states = session.query(States) @@ -180,10 +169,6 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> assert states.count() == 2 assert state_attributes.count() == 1 - assert recorder_mock.states_manager.oldest_ts != oldest_ts - assert recorder_mock.states_manager.oldest_ts == states[0].last_updated_ts - oldest_ts = recorder_mock.states_manager.oldest_ts - assert "test.recorder2" in recorder_mock.states_manager._last_committed_id # run purge_old_data again @@ -196,8 +181,6 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> repack=False, ) assert not finished - # states_manager.oldest_ts is not updated until after the purge is complete - assert recorder_mock.states_manager.oldest_ts == oldest_ts with session_scope(hass=hass) as session: assert states.count() == 0 @@ -1930,8 +1913,6 @@ async def test_purge_old_events_purges_the_event_type_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: """Test deleting old events purges event type ids.""" - assert recorder_mock.event_type_manager.active is True - utcnow = dt_util.utcnow() five_days_ago = utcnow - timedelta(days=5) eleven_days_ago = utcnow - timedelta(days=11) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 4904bdecc4d..99bd5083489 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -9,6 +9,7 @@ import threading from typing import Any from unittest.mock import MagicMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import lambda_stmt, text from sqlalchemy.engine.result import ChunkedIteratorResult @@ -1052,55 +1053,82 @@ async def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=UTC)) -async def test_resolve_period(hass: HomeAssistant) -> None: - """Test statistic_during_period.""" +@pytest.mark.parametrize( + ("start_time", "periods"), + [ + ( + # Test 00:25 local time, during DST + datetime(2022, 10, 21, 7, 25, 50, 123, tzinfo=UTC), + { + ("hour", 0): ("2022-10-21T07:00:00", "2022-10-21T08:00:00"), + ("hour", -1): ("2022-10-21T06:00:00", "2022-10-21T07:00:00"), + ("hour", 1): ("2022-10-21T08:00:00", "2022-10-21T09:00:00"), + ("day", 0): ("2022-10-21T07:00:00", "2022-10-22T07:00:00"), + ("day", -1): ("2022-10-20T07:00:00", "2022-10-21T07:00:00"), + ("day", 1): ("2022-10-22T07:00:00", "2022-10-23T07:00:00"), + ("week", 0): ("2022-10-17T07:00:00", "2022-10-24T07:00:00"), + ("week", -1): ("2022-10-10T07:00:00", "2022-10-17T07:00:00"), + ("week", 1): ("2022-10-24T07:00:00", "2022-10-31T07:00:00"), + ("month", 0): ("2022-10-01T07:00:00", "2022-11-01T07:00:00"), + ("month", -1): ("2022-09-01T07:00:00", "2022-10-01T07:00:00"), + ("month", -12): ("2021-10-01T07:00:00", "2021-11-01T07:00:00"), + ("month", 1): ("2022-11-01T07:00:00", "2022-12-01T08:00:00"), + ("month", 2): ("2022-12-01T08:00:00", "2023-01-01T08:00:00"), + ("month", 3): ("2023-01-01T08:00:00", "2023-02-01T08:00:00"), + ("month", 12): ("2023-10-01T07:00:00", "2023-11-01T07:00:00"), + ("month", 13): ("2023-11-01T07:00:00", "2023-12-01T08:00:00"), + ("month", 14): ("2023-12-01T08:00:00", "2024-01-01T08:00:00"), + ("year", 0): ("2022-01-01T08:00:00", "2023-01-01T08:00:00"), + ("year", -1): ("2021-01-01T08:00:00", "2022-01-01T08:00:00"), + ("year", 1): ("2023-01-01T08:00:00", "2024-01-01T08:00:00"), + }, + ), + ( + # Test 00:25 local time, standard time, February 28th a leap year + datetime(2024, 2, 28, 8, 25, 50, 123, tzinfo=UTC), + { + ("hour", 0): ("2024-02-28T08:00:00", "2024-02-28T09:00:00"), + ("hour", -1): ("2024-02-28T07:00:00", "2024-02-28T08:00:00"), + ("hour", 1): ("2024-02-28T09:00:00", "2024-02-28T10:00:00"), + ("day", 0): ("2024-02-28T08:00:00", "2024-02-29T08:00:00"), + ("day", -1): ("2024-02-27T08:00:00", "2024-02-28T08:00:00"), + ("day", 1): ("2024-02-29T08:00:00", "2024-03-01T08:00:00"), + ("week", 0): ("2024-02-26T08:00:00", "2024-03-04T08:00:00"), + ("week", -1): ("2024-02-19T08:00:00", "2024-02-26T08:00:00"), + ("week", 1): ("2024-03-04T08:00:00", "2024-03-11T07:00:00"), + ("month", 0): ("2024-02-01T08:00:00", "2024-03-01T08:00:00"), + ("month", -1): ("2024-01-01T08:00:00", "2024-02-01T08:00:00"), + ("month", -2): ("2023-12-01T08:00:00", "2024-01-01T08:00:00"), + ("month", -3): ("2023-11-01T07:00:00", "2023-12-01T08:00:00"), + ("month", -12): ("2023-02-01T08:00:00", "2023-03-01T08:00:00"), + ("month", -13): ("2023-01-01T08:00:00", "2023-02-01T08:00:00"), + ("month", -14): ("2022-12-01T08:00:00", "2023-01-01T08:00:00"), + ("month", 1): ("2024-03-01T08:00:00", "2024-04-01T07:00:00"), + ("year", 0): ("2024-01-01T08:00:00", "2025-01-01T08:00:00"), + ("year", -1): ("2023-01-01T08:00:00", "2024-01-01T08:00:00"), + ("year", 1): ("2025-01-01T08:00:00", "2026-01-01T08:00:00"), + }, + ), + ], +) +async def test_resolve_period( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + start_time: datetime, + periods: dict[tuple[str, int], tuple[str, str]], +) -> None: + """Test resolve_period.""" + assert hass.config.time_zone == "US/Pacific" + freezer.move_to(start_time) now = dt_util.utcnow() - start_t, end_t = resolve_period({"calendar": {"period": "hour"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "hour"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "hour", "offset": -1}}) - assert start_t.isoformat() == "2022-10-21T06:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "day"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-22T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "day", "offset": -1}}) - assert start_t.isoformat() == "2022-10-20T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "week"}}) - assert start_t.isoformat() == "2022-10-17T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-24T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "week", "offset": -1}}) - assert start_t.isoformat() == "2022-10-10T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-17T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "month"}}) - assert start_t.isoformat() == "2022-10-01T07:00:00+00:00" - assert end_t.isoformat() == "2022-11-01T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "month", "offset": -1}}) - assert start_t.isoformat() == "2022-09-01T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-01T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "year"}}) - assert start_t.isoformat() == "2022-01-01T08:00:00+00:00" - assert end_t.isoformat() == "2023-01-01T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "year", "offset": -1}}) - assert start_t.isoformat() == "2021-01-01T08:00:00+00:00" - assert end_t.isoformat() == "2022-01-01T08:00:00+00:00" + for period_def, expected_period in periods.items(): + start_t, end_t = resolve_period( + {"calendar": {"period": period_def[0], "offset": period_def[1]}} + ) + assert start_t.isoformat() == f"{expected_period[0]}+00:00" + assert end_t.isoformat() == f"{expected_period[1]}+00:00" # Fixed period assert resolve_period({}) == (None, None) diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 27219788906..51728d02ef3 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -1,7 +1,5 @@ """The tests for the Remote component, adapted from Light Test.""" -import pytest - from homeassistant.components import remote from homeassistant.components.remote import ( ATTR_ALTERNATIVE, @@ -142,23 +140,3 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockRemote(remote.RemoteEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockRemote() - assert entity.supported_features_compat is remote.RemoteEntityFeature(1) - assert "MockRemote" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "RemoteEntityFeature.LEARN_COMMAND" in caplog.text - caplog.clear() - assert entity.supported_features_compat is remote.RemoteEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index b03d87c7a89..4b4dda7227d 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py index 7144c77cad9..57ddf5d51a6 100644 --- a/tests/components/roku/test_config_flow.py +++ b/tests/components/roku/test_config_flow.py @@ -1,13 +1,18 @@ """Test the Roku config flow.""" import dataclasses -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from rokuecp import RokuConnectionError +from rokuecp import Device as RokuDevice, RokuConnectionError from homeassistant.components.roku.const import CONF_PLAY_MEDIA_APP_ID, DOMAIN -from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_HOMEKIT, + SOURCE_SSDP, + SOURCE_USER, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,6 +28,8 @@ from . import ( from tests.common import MockConfigEntry +RECONFIGURE_HOST = "192.168.1.190" + async def test_duplicate_error( hass: HomeAssistant, @@ -276,3 +283,56 @@ async def test_options_flow( assert result2.get("data") == { CONF_PLAY_MEDIA_APP_ID: "782875", } + + +async def _start_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "user" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + {CONF_HOST: RECONFIGURE_HOST}, + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_roku_config_flow: MagicMock, +) -> None: + """Test reconfigure flow.""" + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: RECONFIGURE_HOST, + } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_device: RokuDevice, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_roku_config_flow: MagicMock, +) -> None: + """Ensure reconfigure flow aborts when the device changes.""" + mock_device.info.serial_number = "RECONFIG" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py index a4fc8477ac3..9c414bcf62a 100644 --- a/tests/components/roku/test_init.py +++ b/tests/components/roku/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, patch from rokuecp import RokuConnectionError -from homeassistant.components.roku.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -38,12 +37,7 @@ async def test_config_entry_no_unique_id( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.LOADED - assert ( - hass.data[DOMAIN][mock_config_entry.entry_id].device_id - == mock_config_entry.entry_id - ) async def test_load_unload_config_entry( @@ -56,10 +50,9 @@ async def test_load_unload_config_entry( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/russound_rio/__init__.py b/tests/components/russound_rio/__init__.py index d0e6d77f1ee..d8764285dd3 100644 --- a/tests/components/russound_rio/__init__.py +++ b/tests/components/russound_rio/__init__.py @@ -1,5 +1,9 @@ """Tests for the Russound RIO integration.""" +from unittest.mock import AsyncMock + +from aiorussound.models import CallbackType + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,3 +15,11 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +async def mock_state_update( + client: AsyncMock, callback_type: CallbackType = CallbackType.STATE +) -> None: + """Trigger a callback in the media player.""" + for callback in client.register_state_update_callbacks.call_args_list: + await callback[0][0](client, callback_type) diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 09cccd7d83f..5522c1e6ea2 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components.russound_rio.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import HARDWARE_MAC, HOST, MOCK_CONFIG, MODEL, PORT +from .const import API_VERSION, HARDWARE_MAC, HOST, MOCK_CONFIG, MODEL, PORT from tests.common import MockConfigEntry, load_json_object_fixture @@ -54,6 +54,7 @@ def mock_russound_client() -> Generator[AsyncMock]: int(k): Source.from_dict(v) for k, v in load_json_object_fixture("get_sources.json", DOMAIN).items() } + client.state = load_json_object_fixture("get_state.json", DOMAIN) for k, v in zones.items(): v.device_str = zone_device_str(1, k) v.fetch_current_source = Mock( @@ -70,4 +71,6 @@ def mock_russound_client() -> Generator[AsyncMock]: client.connection_handler = RussoundTcpConnectionHandler(HOST, PORT) client.is_connected = Mock(return_value=True) client.unregister_state_update_callbacks.return_value = True + client.rio_version = API_VERSION + yield client diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py index 3d2924693d2..8f8ae7b59ea 100644 --- a/tests/components/russound_rio/const.py +++ b/tests/components/russound_rio/const.py @@ -8,6 +8,7 @@ HOST = "127.0.0.1" PORT = 9621 MODEL = "MCA-C5" HARDWARE_MAC = "00:11:22:33:44:55" +API_VERSION = "1.08.00" MOCK_CONFIG = { "host": HOST, diff --git a/tests/components/russound_rio/fixtures/get_state.json b/tests/components/russound_rio/fixtures/get_state.json new file mode 100644 index 00000000000..931b7611d01 --- /dev/null +++ b/tests/components/russound_rio/fixtures/get_state.json @@ -0,0 +1,75 @@ +{ + "S": { + "3": { + "name": "Streamer", + "type": "Misc Audio" + }, + "2": { + "name": "Liv. Rm TV", + "type": "Misc Audio" + }, + "5": { + "name": "Source 5", + "type": null + }, + "4": { + "name": "Basement TV", + "type": null + }, + "1": { + "name": "Tuner", + "type": "DMS-3.1 Media Streamer", + "channelName": null, + "coverArtURL": null, + "mode": "Unknown", + "shuffleMode": null, + "repeatMode": null, + "volume": "0", + "rating": null, + "playlistName": "Please Wait...", + "artistName": null, + "albumName": null, + "songName": "Connecting to media source." + }, + "6": { + "name": "Source 6", + "type": null + }, + "8": { + "name": "Source 8", + "type": null + }, + "7": { + "name": "Source 7", + "type": null + } + }, + "System": { + "status": "OFF" + }, + "C": { + "1": { + "Z": { + "1": { + "name": "Deck", + "treble": "0", + "balance": "0", + "loudness": "OFF", + "turnOnVolume": "10", + "doNotDisturb": "OFF", + "currentSource": "2", + "volume": "0", + "status": "OFF", + "mute": "OFF", + "partyMode": "OFF", + "bass": "0", + "page": "OFF", + "sharedSource": "OFF", + "sleepTimeRemaining": "0", + "lastError": null, + "enabled_sources": [3, 2] + } + } + } + } +} diff --git a/tests/components/russound_rio/snapshots/test_diagnostics.ambr b/tests/components/russound_rio/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ff3a8bf757f --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_diagnostics.ambr @@ -0,0 +1,81 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'C': dict({ + '1': dict({ + 'Z': dict({ + '1': dict({ + 'balance': '0', + 'bass': '0', + 'currentSource': '2', + 'doNotDisturb': 'OFF', + 'enabled_sources': list([ + 3, + 2, + ]), + 'lastError': None, + 'loudness': 'OFF', + 'mute': 'OFF', + 'name': 'Deck', + 'page': 'OFF', + 'partyMode': 'OFF', + 'sharedSource': 'OFF', + 'sleepTimeRemaining': '0', + 'status': 'OFF', + 'treble': '0', + 'turnOnVolume': '10', + 'volume': '0', + }), + }), + }), + }), + 'S': dict({ + '1': dict({ + 'albumName': None, + 'artistName': None, + 'channelName': None, + 'coverArtURL': None, + 'mode': 'Unknown', + 'name': 'Tuner', + 'playlistName': 'Please Wait...', + 'rating': None, + 'repeatMode': None, + 'shuffleMode': None, + 'songName': 'Connecting to media source.', + 'type': 'DMS-3.1 Media Streamer', + 'volume': '0', + }), + '2': dict({ + 'name': 'Liv. Rm TV', + 'type': 'Misc Audio', + }), + '3': dict({ + 'name': 'Streamer', + 'type': 'Misc Audio', + }), + '4': dict({ + 'name': 'Basement TV', + 'type': None, + }), + '5': dict({ + 'name': 'Source 5', + 'type': None, + }), + '6': dict({ + 'name': 'Source 6', + 'type': None, + }), + '7': dict({ + 'name': 'Source 7', + 'type': None, + }), + '8': dict({ + 'name': 'Source 8', + 'type': None, + }), + }), + 'System': dict({ + 'status': 'OFF', + }), + }) +# --- diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index cf754852731..28cbf7eda5e 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -9,6 +9,8 @@ from homeassistant.data_entry_flow import FlowResultType from .const import MOCK_CONFIG, MODEL +from tests.common import MockConfigEntry + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock @@ -29,6 +31,7 @@ async def test_form( assert result["title"] == MODEL assert result["data"] == MOCK_CONFIG assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "00:11:22:33:44:55" async def test_form_cannot_connect( @@ -60,6 +63,31 @@ async def test_form_cannot_connect( assert len(mock_setup_entry.mock_calls) == 1 +async def test_duplicate( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_import( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: @@ -74,6 +102,7 @@ async def test_import( assert result["title"] == MODEL assert result["data"] == MOCK_CONFIG assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "00:11:22:33:44:55" async def test_import_cannot_connect( diff --git a/tests/components/russound_rio/test_diagnostics.py b/tests/components/russound_rio/test_diagnostics.py new file mode 100644 index 00000000000..c6c5441128d --- /dev/null +++ b/tests/components/russound_rio/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Russound RIO integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py index 6787ee37c79..e7022fa6ac1 100644 --- a/tests/components/russound_rio/test_init.py +++ b/tests/components/russound_rio/test_init.py @@ -1,7 +1,9 @@ """Tests for the Russound RIO integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock +from aiorussound.models import CallbackType +import pytest from syrupy import SnapshotAssertion from homeassistant.components.russound_rio.const import DOMAIN @@ -9,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import mock_state_update, setup_integration from tests.common import MockConfigEntry @@ -42,3 +44,23 @@ async def test_device_info( ) assert device_entry is not None assert device_entry == snapshot + + +async def test_disconnect_reconnect_log( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + + mock_russound_client.is_connected = Mock(return_value=False) + await mock_state_update(mock_russound_client, CallbackType.CONNECTION) + assert "Disconnected from device at 127.0.0.1" in caplog.text + + mock_russound_client.is_connected = Mock(return_value=True) + await mock_state_update(mock_russound_client, CallbackType.CONNECTION) + assert "Reconnected to device at 127.0.0.1" in caplog.text diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index e720e2c7f65..c740ec4f39e 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiorussound.models import CallbackType, PlayStatus +from aiorussound.models import PlayStatus import pytest from homeassistant.const import ( @@ -15,18 +15,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import setup_integration +from . import mock_state_update, setup_integration from .const import ENTITY_ID_ZONE_1 from tests.common import MockConfigEntry -async def mock_state_update(client: AsyncMock) -> None: - """Trigger a callback in the media player.""" - for callback in client.register_state_update_callbacks.call_args_list: - await callback[0][0](client, CallbackType.STATE) - - @pytest.mark.parametrize( ("zone_status", "source_play_status", "media_player_state"), [ diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 3a849c9d4b1..eb78332b7b3 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -14,8 +14,6 @@ from samsungtvws.exceptions import ( UnauthorizedError, ) from websockets import frames - -# pylint: disable-next=no-name-in-module from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 0319d5dd8dd..e8e0b699a7e 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -51,6 +51,7 @@ async def test_entry_diagnostics( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -91,6 +92,7 @@ async def test_entry_diagnostics_encrypted( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -130,6 +132,7 @@ async def test_entry_diagnostics_encrypte_offline( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 237d3eab257..c7db7a33959 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Pentair: DD-EE-FF', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 8be9f4a60e4..7916727e57a 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -347,6 +347,17 @@ async def test_climate_temperatures( state2 = hass.states.get("climate.hallway") assert state2.attributes["temperature"] == 20 + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_set_ac_state_property", + ) as mock_call: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 20}, + blocking=True, + ) + assert not mock_call.called + with ( patch( "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index 3748cfd6dc4..bfb2176026b 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -35,10 +35,7 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -160,7 +157,7 @@ async def test_simple_properties( assert entity assert state - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert entity.unique_id == "AC000Wxxxxxxxxx" @@ -189,10 +186,10 @@ async def test_initial_attributes( @pytest.mark.parametrize( ("service", "target_state"), [ - (SERVICE_STOP, STATE_IDLE), - (SERVICE_PAUSE, STATE_PAUSED), - (SERVICE_RETURN_TO_BASE, STATE_RETURNING), - (SERVICE_START, STATE_CLEANING), + (SERVICE_STOP, VacuumActivity.IDLE), + (SERVICE_PAUSE, VacuumActivity.PAUSED), + (SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (SERVICE_START, VacuumActivity.CLEANING), ], ) async def test_cleaning_states( diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index d5479f00b06..13c1e28aa36 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -32,6 +32,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "subscription_data": { "12345": { diff --git a/tests/components/simulated/__init__.py b/tests/components/simulated/__init__.py deleted file mode 100644 index 501fbab603a..00000000000 --- a/tests/components/simulated/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the simulated component.""" diff --git a/tests/components/simulated/test_sensor.py b/tests/components/simulated/test_sensor.py deleted file mode 100644 index b167147367a..00000000000 --- a/tests/components/simulated/test_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The tests for the simulated sensor.""" - -from homeassistant.components.simulated.sensor import ( - CONF_AMP, - CONF_FWHM, - CONF_MEAN, - CONF_PERIOD, - CONF_PHASE, - CONF_RELATIVE_TO_EPOCH, - CONF_SEED, - CONF_UNIT, - DEFAULT_AMP, - DEFAULT_FWHM, - DEFAULT_MEAN, - DEFAULT_NAME, - DEFAULT_PHASE, - DEFAULT_RELATIVE_TO_EPOCH, - DEFAULT_SEED, - DOMAIN, -) -from homeassistant.const import CONF_FRIENDLY_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - - -async def test_simulated_sensor_default_config( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test default config.""" - config = {"sensor": {"platform": "simulated"}} - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids()) == 1 - state = hass.states.get("sensor.simulated") - - assert state.attributes.get(CONF_FRIENDLY_NAME) == DEFAULT_NAME - assert state.attributes.get(CONF_AMP) == DEFAULT_AMP - assert state.attributes.get(CONF_UNIT) is None - assert state.attributes.get(CONF_MEAN) == DEFAULT_MEAN - assert state.attributes.get(CONF_PERIOD) == 60.0 - assert state.attributes.get(CONF_PHASE) == DEFAULT_PHASE - assert state.attributes.get(CONF_FWHM) == DEFAULT_FWHM - assert state.attributes.get(CONF_SEED) == DEFAULT_SEED - assert state.attributes.get(CONF_RELATIVE_TO_EPOCH) == DEFAULT_RELATIVE_TO_EPOCH - - issue = issue_registry.async_get_issue(DOMAIN, DOMAIN) - assert issue.issue_id == DOMAIN - assert issue.translation_key == "simulated_deprecation" diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 68a4eb03998..b78d25366fa 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock import pytest -from homeassistant.components import siren from homeassistant.components.siren import ( SirenEntity, SirenEntityDescription, @@ -106,20 +105,3 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": 3}) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockSirenEntity(siren.SirenEntity): - _attr_supported_features = 1 - - entity = MockSirenEntity() - assert entity.supported_features is siren.SirenEntityFeature(1) - assert "MockSirenEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "SirenEntityFeature.TURN_ON" in caplog.text - caplog.clear() - assert entity.supported_features is siren.SirenEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/slide_local/__init__.py b/tests/components/slide_local/__init__.py new file mode 100644 index 00000000000..cd7bd6cb6d1 --- /dev/null +++ b/tests/components/slide_local/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the slide_local integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Set up the slide local integration.""" + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.slide_local.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/slide_local/conftest.py b/tests/components/slide_local/conftest.py new file mode 100644 index 00000000000..ad2734bbb64 --- /dev/null +++ b/tests/components/slide_local/conftest.py @@ -0,0 +1,61 @@ +"""Test fixtures for Slide local.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC + +from .const import HOST, SLIDE_INFO_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="slide", + data={ + CONF_HOST: HOST, + CONF_API_VERSION: 2, + CONF_MAC: "12:34:56:78:90:ab", + }, + options={ + CONF_INVERT_POSITION: False, + }, + minor_version=1, + unique_id="12:34:56:78:90:ab", + entry_id="ce5f5431554d101905d31797e1232da8", + ) + + +@pytest.fixture +def mock_slide_api() -> Generator[AsyncMock]: + """Build a fixture for the SlideLocalApi that connects successfully and returns one device.""" + + with ( + patch( + "homeassistant.components.slide_local.coordinator.SlideLocalApi", + autospec=True, + ) as mock_slide_local_api, + patch( + "homeassistant.components.slide_local.config_flow.SlideLocalApi", + new=mock_slide_local_api, + ), + ): + client = mock_slide_local_api.return_value + client.slide_info.return_value = SLIDE_INFO_DATA + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.slide_local.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/slide_local/const.py b/tests/components/slide_local/const.py new file mode 100644 index 00000000000..edf45753407 --- /dev/null +++ b/tests/components/slide_local/const.py @@ -0,0 +1,8 @@ +"""Common const used across tests for slide_local.""" + +from homeassistant.components.slide_local.const import DOMAIN + +from tests.common import load_json_object_fixture + +HOST = "127.0.0.2" +SLIDE_INFO_DATA = load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/fixtures/slide_1.json b/tests/components/slide_local/fixtures/slide_1.json new file mode 100644 index 00000000000..6367b94f243 --- /dev/null +++ b/tests/components/slide_local/fixtures/slide_1.json @@ -0,0 +1,11 @@ +{ + "slide_id": "slide_1234567890ab", + "mac": "1234567890ab", + "board_rev": 1, + "device_name": "slide bedroom", + "zone_name": "bedroom", + "curtain_type": 0, + "calib_time": 10239, + "pos": 0.0, + "touch_go": true +} diff --git a/tests/components/slide_local/snapshots/test_button.ambr b/tests/components/slide_local/snapshots/test_button.ambr new file mode 100644 index 00000000000..549538f1361 --- /dev/null +++ b/tests/components/slide_local/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.slide_bedroom_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.slide_bedroom_calibrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calibrate', + 'platform': 'slide_local', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': '1234567890ab-calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.slide_bedroom_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'slide bedroom Calibrate', + }), + 'context': , + 'entity_id': 'button.slide_bedroom_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr new file mode 100644 index 00000000000..d90f72e4b05 --- /dev/null +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.2', + 'connections': set({ + tuple( + 'mac', + '12:34:56:78:90:ab', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 1, + 'id': , + 'identifiers': set({ + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Innovation in Motion', + 'model': None, + 'model_id': None, + 'name': 'slide bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890ab', + 'suggested_area': None, + 'sw_version': 2, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/slide_local/snapshots/test_switch.ambr b/tests/components/slide_local/snapshots/test_switch.ambr new file mode 100644 index 00000000000..e19467c283e --- /dev/null +++ b/tests/components/slide_local/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[switch.slide_bedroom_touchgo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.slide_bedroom_touchgo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TouchGo', + 'platform': 'slide_local', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'touchgo', + 'unique_id': '1234567890ab-touchgo', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.slide_bedroom_touchgo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'slide bedroom TouchGo', + }), + 'context': , + 'entity_id': 'switch.slide_bedroom_touchgo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/slide_local/test_button.py b/tests/components/slide_local/test_button.py new file mode 100644 index 00000000000..646c8fd7ef3 --- /dev/null +++ b/tests/components/slide_local/test_button.py @@ -0,0 +1,46 @@ +"""Tests for the Slide Local button platform.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_platform(hass, mock_config_entry, [Platform.BUTTON]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_button( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing button.""" + await setup_platform(hass, mock_config_entry, [Platform.BUTTON]) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.slide_bedroom_calibrate", + }, + blocking=True, + ) + mock_slide_api.slide_calibrate.assert_called_once() diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py new file mode 100644 index 00000000000..48be7dd7850 --- /dev/null +++ b/tests/components/slide_local/test_config_flow.py @@ -0,0 +1,398 @@ +"""Test the slide_local config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) +import pytest + +from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_platform +from .const import HOST, SLIDE_INFO_DATA + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], + hostname="Slide-1234567890AB.local.", + name="Slide-1234567890AB._http._tcp.local.", + port=80, + properties={ + "id": "slide-1234567890AB", + "arch": "esp32", + "app": "slide", + "fw_version": "2.0.0-1683059251", + "fw_id": "20230502-202745", + }, + type="mock_type", +) + + +async def test_user( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 2 + assert result2["result"].unique_id == "12:34:56:78:90:ab" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_api_1( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 1 + assert result2["result"].unique_id == "12:34:56:78:90:ab" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_api_error( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, None] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 1 + assert result2["result"].unique_id == "12:34:56:78:90:ab" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ClientConnectionError, "cannot_connect"), + (ClientTimeoutError, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (DigestAuthCalcError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_api_1_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_slide_api: AsyncMock, +) -> None: + """Test we can handle Form exceptions for api 1.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, exception] + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # tests with all provided + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ClientConnectionError, "cannot_connect"), + (ClientTimeoutError, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (DigestAuthCalcError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_api_2_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_slide_api: AsyncMock, +) -> None: + """Test we can handle Form exceptions for api 2.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = exception + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # tests with all provided + mock_slide_api.slide_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_already_setup( + hass: HomeAssistant, + mock_slide_api: AsyncMock, +) -> None: + """Test we abort if the device is already setup.""" + + MockConfigEntry(domain=DOMAIN, unique_id="12:34:56:78:90:ab").add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow from discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.2" + assert result["data"][CONF_HOST] == "127.0.0.2" + assert not result["options"][CONF_INVERT_POSITION] + assert result["result"].unique_id == "12:34:56:78:90:ab" + + +async def test_zeroconf_duplicate_entry( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: HOST}, unique_id="12:34:56:78:90:ab" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == HOST + + +async def test_zeroconf_update_duplicate_entry( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test updating an existing entry from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.3"}, unique_id="12:34:56:78:90:ab" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == HOST + + +@pytest.mark.parametrize( + ("exception"), + [ + (ClientConnectionError), + (ClientTimeoutError), + (AuthenticationFailed), + (DigestAuthCalcError), + (Exception), + ], +) +async def test_zeroconf_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test starting a flow from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "slide_host"}, unique_id="12:34:56:78:90:cd" + ).add_to_hass(hass) + + mock_slide_api.slide_info.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_connection_failed" + + +async def test_options_flow( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow works correctly.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_INVERT_POSITION: True, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options == { + CONF_INVERT_POSITION: True, + } diff --git a/tests/components/slide_local/test_init.py b/tests/components/slide_local/test_init.py new file mode 100644 index 00000000000..7b0a2d83164 --- /dev/null +++ b/tests/components/slide_local/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Slide Local integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_platform + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "1234567890ab")} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/slide_local/test_switch.py b/tests/components/slide_local/test_switch.py new file mode 100644 index 00000000000..0ac9820ca10 --- /dev/null +++ b/tests/components/slide_local/test_switch.py @@ -0,0 +1,61 @@ +"""Tests for the Slide Local switch platform.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_platform(hass, mock_config_entry, [Platform.SWITCH]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_TOGGLE, + ], +) +async def test_services( + hass: HomeAssistant, + service: str, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch.""" + await setup_platform(hass, mock_config_entry, [Platform.SWITCH]) + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: "switch.slide_bedroom_touchgo", + }, + blocking=True, + ) + mock_slide_api.slide_set_touchgo.assert_called_once() diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 22b181a3645..b46188b5b5f 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -101,8 +101,8 @@ async def test_entity_state(hass: HomeAssistant, light_devices) -> None: assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_BRIGHTNESS] == 255 assert ATTR_HS_COLOR not in state.attributes[ATTR_HS_COLOR] - assert isinstance(state.attributes[ATTR_COLOR_TEMP], int) - assert state.attributes[ATTR_COLOR_TEMP] == 222 + assert isinstance(state.attributes[ATTR_COLOR_TEMP_KELVIN], int) + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4500 async def test_entity_and_device_attributes( @@ -273,7 +273,7 @@ async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> No await hass.services.async_call( "light", "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP: 300}, + {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP_KELVIN: 3333}, blocking=True, ) # This test schedules and update right after the call @@ -282,7 +282,7 @@ async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> No state = hass.states.get("light.color_dimmer_2") assert state is not None assert state.state == "on" - assert state.attributes[ATTR_COLOR_TEMP] == 300 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3333 async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index e0f1bc2623c..6aef72ebbd5 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'solarlog', 'unique_id': None, 'version': 1, diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index 3de3c08fcd0..58a5faa0772 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -9,7 +9,6 @@ from solarlog_cli.solarlog_exceptions import ( SolarLogError, ) -from homeassistant.components.solarlog import config_flow from homeassistant.components.solarlog.const import CONF_HAS_PWD, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD @@ -35,7 +34,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result["flow_id"], {CONF_HOST: HOST, CONF_HAS_PWD: False}, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == HOST @@ -44,13 +42,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -def init_config_flow(hass: HomeAssistant) -> config_flow.SolarLogConfigFlow: - """Init a configuration flow.""" - flow = config_flow.SolarLogConfigFlow() - flow.hass = hass - return flow - - @pytest.mark.usefixtures("test_connect") async def test_user( hass: HomeAssistant, @@ -68,7 +59,6 @@ async def test_user( result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: HOST, CONF_HAS_PWD: False} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -97,17 +87,19 @@ async def test_form_exceptions( mock_solarlog_connector: AsyncMock, ) -> None: """Test we can handle Form exceptions.""" - flow = init_config_flow(hass) - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["errors"] == {} mock_solarlog_connector.test_connection.side_effect = exception1 # tests with connection error - result = await flow.async_step_user({CONF_HOST: HOST, CONF_HAS_PWD: False}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST, CONF_HAS_PWD: False} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -117,14 +109,16 @@ async def test_form_exceptions( mock_solarlog_connector.test_connection.side_effect = None mock_solarlog_connector.test_extended_data_available.side_effect = exception2 - result = await flow.async_step_user({CONF_HOST: HOST, CONF_HAS_PWD: True}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST, CONF_HAS_PWD: True} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" - result = await flow.async_step_password({CONF_PASSWORD: "pwd"}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "pwd"} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" @@ -132,18 +126,10 @@ async def test_form_exceptions( mock_solarlog_connector.test_extended_data_available.side_effect = None - # tests with all provided (no password) - result = await flow.async_step_user({CONF_HOST: HOST, CONF_HAS_PWD: False}) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_HAS_PWD] is False - - # tests with all provided (password) - result = await flow.async_step_password({CONF_PASSWORD: "pwd"}) - await hass.async_block_till_done() + # tests with all provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "pwd"} + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -205,7 +191,6 @@ async def test_reconfigure_flow( result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HAS_PWD: True, CONF_PASSWORD: password} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -257,7 +242,6 @@ async def test_reauth( result["flow_id"], {CONF_PASSWORD: "other_pwd"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -270,7 +254,6 @@ async def test_reauth( result["flow_id"], {CONF_PASSWORD: "other_pwd"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 0de96d05605..8c0e897947a 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -6,7 +6,12 @@ from unittest.mock import patch from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.spaceapi import DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI +from homeassistant.components.spaceapi import ( + ATTR_SENSOR_LOCATION, + DOMAIN, + SPACEAPI_VERSION, + URL_API_SPACEAPI, +) from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +32,7 @@ CONFIG = { "icon_closed": "https://home-assistant.io/close.png", }, "sensors": { - "temperature": ["test.temp1", "test.temp2"], + "temperature": ["test.temp1", "test.temp2", "test.temp3"], "humidity": ["test.hum1"], }, "spacefed": {"spacenet": True, "spacesaml": False, "spacephone": True}, @@ -67,17 +72,23 @@ SENSOR_OUTPUT = { "location": "Home", "name": "temp1", "unit": UnitOfTemperature.CELSIUS, - "value": "25", + "value": 25.0, + }, + { + "location": "outside", + "name": "temp2", + "unit": UnitOfTemperature.CELSIUS, + "value": 23.0, }, { "location": "Home", - "name": "temp2", + "name": "temp3", "unit": UnitOfTemperature.CELSIUS, - "value": "23", + "value": None, }, ], "humidity": [ - {"location": "Home", "name": "hum1", "unit": PERCENTAGE, "value": "88"} + {"location": "Home", "name": "hum1", "unit": PERCENTAGE, "value": 88.0} ], } @@ -96,6 +107,19 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes hass.states.async_set( "test.temp2", 23, + attributes={ + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_SENSOR_LOCATION: "outside", + }, + ) + hass.states.async_set( + "test.temp3", + "foo", + attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "test.temp3", + "foo", attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) hass.states.async_set( diff --git a/tests/components/starlink/fixtures/history_stats_success.json b/tests/components/starlink/fixtures/history_stats_success.json new file mode 100644 index 00000000000..5a228dd34af --- /dev/null +++ b/tests/components/starlink/fixtures/history_stats_success.json @@ -0,0 +1,112 @@ +[ + { + "samples": 900, + "end_counter": 119395 + }, + { + "total_ping_drop": 2.4592087380588055, + "count_full_ping_drop": 0, + "count_obstructed": 0, + "total_obstructed_ping_drop": 0, + "count_full_obstructed_ping_drop": 0, + "count_unscheduled": 0, + "total_unscheduled_ping_drop": 0, + "count_full_unscheduled_ping_drop": 0 + }, + { + "init_run_fragment": 0, + "final_run_fragment": 0, + "run_seconds[1,]": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ], + "run_minutes[1,]": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + }, + { + "mean_all_ping_latency": 31.55747121333472, + "deciles_all_ping_latency[]": [ + 21.005102157592773, 22.67989158630371, 25.310760498046875, + 26.85667610168457, 27.947458267211914, 29.192155838012695, + 31.26323890686035, 34.226768493652344, 38.54373550415039, + 42.308048248291016, 60.11151885986328 + ], + "mean_full_ping_latency": 31.526783029284427, + "deciles_full_ping_latency[]": [ + 21.070240020751953, 22.841461181640625, 25.34041976928711, + 26.908039093017578, 27.947458267211914, 29.135879516601562, + 31.122955322265625, 34.1280403137207, 38.49388122558594, + 42.308048248291016, 60.11151885986328 + ], + "stdev_full_ping_latency": 7.8141330200011785 + }, + { + "load_bucket_samples[]": [738, 24, 39, 55, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "load_bucket_min_latency[]": [ + 21.070240020751953, + 21.35713768005371, + 21.156545639038086, + 24.763751983642578, + 24.7109317779541, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "load_bucket_median_latency[]": [ + 29.2450590133667, + 27.031108856201172, + 25.726211547851562, + 31.845806121826172, + 28.919479370117188, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "load_bucket_max_latency[]": [ + 60.11151885986328, + 40.572628021240234, + 48.063961029052734, + 53.505126953125, + 38.7435302734375, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "download_usage": 72504227, + "upload_usage": 5719755 + }, + { + "latest_power": 27.54502296447754, + "mean_power": 31.449254739549424, + "min_power": 21.826229095458984, + "max_power": 41.71160888671875, + "total_energy": 0.007862313684887356 + } +] diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index f8179f07bed..08e82548ef8 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -24,6 +24,11 @@ SLEEP_DATA_SUCCESS_PATCHER = patch( return_value=json.loads(load_fixture("sleep_data_success.json", "starlink")), ) +HISTORY_STATS_SUCCESS_PATCHER = patch( + "homeassistant.components.starlink.coordinator.history_stats", + return_value=json.loads(load_fixture("history_stats_success.json", "starlink")), +) + DEVICE_FOUND_PATCHER = patch( "homeassistant.components.starlink.config_flow.get_id", return_value="some-valid-id" ) diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr index 4c85ad84ca7..c54e0b2df6d 100644 --- a/tests/components/starlink/snapshots/test_diagnostics.ambr +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -16,6 +16,13 @@ 'alert_thermal_throttle': False, 'alert_unexpected_location': False, }), + 'consumption': dict({ + 'latest_power': 27.54502296447754, + 'max_power': 41.71160888671875, + 'mean_power': 31.449254739549424, + 'min_power': 21.826229095458984, + 'total_energy': 0.007862313684887356, + }), 'location': dict({ 'altitude': '**REDACTED**', 'latitude': '**REDACTED**', @@ -79,5 +86,9 @@ 'uplink_throughput_bps': 11802.771484375, 'uptime': 804138, }), + 'usage': dict({ + 'download_usage': 72504227, + 'upload_usage': 5719755, + }), }) # --- diff --git a/tests/components/starlink/test_diagnostics.py b/tests/components/starlink/test_diagnostics.py index c5876e5e9f2..cd36dd0367e 100644 --- a/tests/components/starlink/test_diagnostics.py +++ b/tests/components/starlink/test_diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from .patchers import ( + HISTORY_STATS_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER, @@ -32,6 +33,7 @@ async def test_diagnostics( STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, ): entry.add_to_hass(hass) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 62a1ee41236..7e04c21562a 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from .patchers import ( + HISTORY_STATS_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER, @@ -25,6 +26,7 @@ async def test_successful_entry(hass: HomeAssistant) -> None: STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, ): entry.add_to_hass(hass) @@ -46,6 +48,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, ): entry.add_to_hass(hass) diff --git a/tests/components/stookalert/__init__.py b/tests/components/stookalert/__init__.py deleted file mode 100644 index 3785c76639a..00000000000 --- a/tests/components/stookalert/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Stookalert integration.""" diff --git a/tests/components/stookalert/test_config_flow.py b/tests/components/stookalert/test_config_flow.py deleted file mode 100644 index 3664527cbcf..00000000000 --- a/tests/components/stookalert/test_config_flow.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tests for the Stookalert config flow.""" - -from unittest.mock import patch - -from homeassistant.components.stookalert.const import CONF_PROVINCE, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_full_user_flow(hass: HomeAssistant) -> None: - """Test the full user configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - with patch( - "homeassistant.components.stookalert.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PROVINCE: "Overijssel", - }, - ) - - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Overijssel" - assert result2.get("data") == { - CONF_PROVINCE: "Overijssel", - } - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_already_configured(hass: HomeAssistant) -> None: - """Test we abort if the Stookalert province is already configured.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_PROVINCE: "Overijssel"}, unique_id="Overijssel" - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PROVINCE: "Overijssel", - }, - ) - - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "already_configured" diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index 6abc544c92a..0b45546902b 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -136,6 +136,7 @@ async def test_user_form_pin_not_required( "data": deepcopy(TEST_CONFIG), "options": {}, "minor_version": 1, + "subentries": (), } expected["data"][CONF_PIN] = None @@ -341,6 +342,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "data": TEST_CONFIG, "options": {}, "minor_version": 1, + "subentries": (), } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index 5e48b7db965..5f724a2d7e7 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -3,7 +3,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, @@ -57,7 +57,7 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.attributes["supported_features"] == 0 assert state.attributes.get(ATTR_BRIGHTNESS) is None assert state.attributes.get(ATTR_HS_COLOR) is None - assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.ONOFF] diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 518c36616ee..58172a6962d 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -60,19 +60,11 @@ def mock_api(): patchers = [ patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", + "homeassistant.components.switcher_kis.entity.SwitcherApi.connect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", - new=api_mock, - ), - patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.connect", - new=api_mock, - ), - patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.disconnect", + "homeassistant.components.switcher_kis.entity.SwitcherApi.disconnect", new=api_mock, ), ] diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index e9d96673e24..defe970c674 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -3,6 +3,7 @@ from aioswitcher.device import ( DeviceState, DeviceType, + ShutterChildLock, ShutterDirection, SwitcherDualShutterSingleLight, SwitcherLight, @@ -90,6 +91,8 @@ DUMMY_POSITION = [54] DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] +DUMMY_CHILD_LOCK = [ShutterChildLock.OFF] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.OFF, ShutterChildLock.OFF] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] @@ -135,6 +138,7 @@ DUMMY_SHUTTER_DEVICE = SwitcherShutter( DUMMY_TOKEN_NEEDED4, DUMMY_POSITION, DUMMY_DIRECTION, + DUMMY_CHILD_LOCK, ) DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE = SwitcherSingleShutterDualLight( @@ -148,6 +152,7 @@ DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE = SwitcherSingleShutterDualLight( DUMMY_TOKEN_NEEDED5, DUMMY_POSITION, DUMMY_DIRECTION, + DUMMY_CHILD_LOCK, DUMMY_LIGHT_2, ) @@ -162,6 +167,7 @@ DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE = SwitcherDualShutterSingleLight( DUMMY_TOKEN_NEEDED6, DUMMY_POSITION_2, DUMMY_DIRECTION_2, + DUMMY_CHILD_LOCK_2, DUMMY_LIGHT, ) diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py index 50c015b4024..6ebd82363e4 100644 --- a/tests/components/switcher_kis/test_button.py +++ b/tests/components/switcher_kis/test_button.py @@ -42,7 +42,7 @@ async def test_assume_button( assert hass.states.get(SWING_OFF_EID) is None with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( BUTTON_DOMAIN, @@ -79,7 +79,7 @@ async def test_swing_button( assert hass.states.get(SWING_OFF_EID) is not None with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( BUTTON_DOMAIN, @@ -103,7 +103,7 @@ async def test_control_device_fail( # Test exception during set hvac mode with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -130,7 +130,7 @@ async def test_control_device_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index 72e1a93d1c3..72a25d20d04 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -49,7 +49,7 @@ async def test_climate_hvac_mode( # Test set hvac mode heat with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -71,7 +71,7 @@ async def test_climate_hvac_mode( # Test set hvac mode off with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -108,7 +108,7 @@ async def test_climate_temperature( # Test set target temperature with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -128,7 +128,7 @@ async def test_climate_temperature( # Test set target temperature - incorrect params with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -160,7 +160,7 @@ async def test_climate_fan_level( # Test set fan level to high with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -195,7 +195,7 @@ async def test_climate_swing( # Test set swing mode on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -218,7 +218,7 @@ async def test_climate_swing( # Test set swing mode off with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -249,7 +249,7 @@ async def test_control_device_fail(hass: HomeAssistant, mock_bridge, mock_api) - # Test exception during set hvac mode with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -276,7 +276,7 @@ async def test_control_device_fail(hass: HomeAssistant, mock_bridge, mock_api) - # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py index 2936cafdd53..5829d6345ef 100644 --- a/tests/components/switcher_kis/test_cover.py +++ b/tests/components/switcher_kis/test_cover.py @@ -115,7 +115,7 @@ async def test_cover( # Test set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -136,7 +136,7 @@ async def test_cover( # Test open with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -156,7 +156,7 @@ async def test_cover( # Test close with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -176,7 +176,7 @@ async def test_cover( # Test stop with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.stop_shutter" + "homeassistant.components.switcher_kis.entity.SwitcherApi.stop_shutter" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -232,7 +232,7 @@ async def test_cover_control_fail( # Test exception during set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -257,7 +257,7 @@ async def test_cover_control_fail( # Test error response during set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 53572085f9b..f59958420c4 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -69,5 +69,6 @@ async def test_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, } diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index aa7d6551d75..51d0eb6332f 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -86,7 +86,7 @@ async def test_light( # Test turning on light with patch( - "homeassistant.components.switcher_kis.light.SwitcherApi.set_light", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light", ) as mock_set_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -99,7 +99,7 @@ async def test_light( # Test turning off light with patch( - "homeassistant.components.switcher_kis.light.SwitcherApi.set_light" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light" ) as mock_set_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -153,7 +153,7 @@ async def test_light_control_fail( # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_light", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -178,7 +178,7 @@ async def test_light_control_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_light", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py index 65e1967cbac..b4a8168419f 100644 --- a/tests/components/switcher_kis/test_services.py +++ b/tests/components/switcher_kis/test_services.py @@ -16,6 +16,7 @@ from homeassistant.components.switcher_kis.const import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import time_period_str from homeassistant.util import slugify @@ -48,7 +49,7 @@ async def test_turn_on_with_timer_service( assert state.state == STATE_OFF with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device" ) as mock_control_device: await hass.services.async_call( DOMAIN, @@ -78,7 +79,7 @@ async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api) entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_auto_shutdown" ) as mock_set_auto_shutdown: await hass.services.async_call( DOMAIN, @@ -95,7 +96,7 @@ async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api) @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) async def test_set_auto_off_service_fail( - hass: HomeAssistant, mock_bridge, mock_api, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, mock_bridge, mock_api ) -> None: """Test set auto off service failed.""" await init_integration(hass) @@ -105,24 +106,21 @@ async def test_set_auto_off_service_fail( entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_auto_shutdown", return_value=None, ) as mock_set_auto_shutdown: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) assert mock_api.call_count == 2 mock_set_auto_shutdown.assert_called_once_with( time_period_str(DUMMY_AUTO_OFF_SET) ) - assert ( - f"Call api for {device.name} failed, api: 'set_auto_shutdown'" - in caplog.text - ) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 443c7bc930d..9bfe11fe202 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -16,6 +16,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from . import init_integration @@ -47,7 +48,7 @@ async def test_switch( # Test turning on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device", ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -60,7 +61,7 @@ async def test_switch( # Test turning off with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device" ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -78,7 +79,6 @@ async def test_switch_control_fail( mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, ) -> None: """Test switch control fail.""" await init_integration(hass) @@ -97,18 +97,19 @@ async def test_switch_control_fail( # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) assert mock_api.call_count == 2 mock_control_device.assert_called_once_with(Command.ON) - assert ( - f"Call api for {device.name} failed, api: 'control_device'" in caplog.text - ) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE @@ -121,17 +122,18 @@ async def test_switch_control_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) assert mock_api.call_count == 4 mock_control_device.assert_called_once_with(Command.ON) - assert ( - f"Call api for {device.name} failed, api: 'control_device'" in caplog.text - ) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 75d942fc601..afa508cc004 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -56,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, @@ -111,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, diff --git a/tests/components/tag/snapshots/test_init.ambr b/tests/components/tag/snapshots/test_init.ambr index 29a9a2665b8..caa88b8ca9a 100644 --- a/tests/components/tag/snapshots/test_init.ambr +++ b/tests/components/tag/snapshots/test_init.ambr @@ -5,8 +5,6 @@ 'items': list([ dict({ 'id': 'test tag id', - 'migrated': True, - 'name': 'test tag name', }), dict({ 'device_id': 'some_scanner', @@ -23,3 +21,24 @@ 'version': 1, }) # --- +# name: test_tag_scanned + dict({ + 'data': dict({ + 'items': list([ + dict({ + 'id': 'test tag id', + }), + dict({ + 'id': 'test tag id 2', + }), + dict({ + 'device_id': 'some_scanner', + 'id': 'new tag', + }), + ]), + }), + 'key': 'tag', + 'minor_version': 3, + 'version': 1, + }) +# --- diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 5c1e80c2d8b..ac862e59f2d 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -6,6 +6,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.tag import DOMAIN, _create_entry, async_scan_tag from homeassistant.const import CONF_NAME, STATE_UNKNOWN @@ -165,7 +166,9 @@ async def test_tag_scanned( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], storage_setup, + snapshot: SnapshotAssertion, ) -> None: """Test scanning tags.""" assert await storage_setup() @@ -205,6 +208,12 @@ async def test_tag_scanned( }, ] + # Trigger store + freezer.tick(11) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot(exclude=props("last_scanned")) + def track_changes(coll: collection.ObservableCollection): """Create helper to track changes in a collection.""" diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr deleted file mode 100644 index 09bf25cb96e..00000000000 --- a/tests/components/tailwind/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,89 +0,0 @@ -# serializer version: 1 -# name: test_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': '3c:e9:0e:6d:21:84', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'tailwind', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'tailwind', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'Tailwind iQ3', - 'unique_id': '3c:e9:0e:6d:21:84', - 'version': 1, - }), - 'title': 'Tailwind iQ3', - 'type': , - 'version': 1, - }) -# --- -# name: test_zeroconf_flow - FlowResultSnapshot({ - 'context': dict({ - 'configuration_url': 'https://web.gotailwind.com/client/integration/local-control-key', - 'source': 'zeroconf', - 'title_placeholders': dict({ - 'name': 'Tailwind iQ3', - }), - 'unique_id': '3c:e9:0e:6d:21:84', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'tailwind', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'tailwind', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'zeroconf', - 'title': 'Tailwind iQ3', - 'unique_id': '3c:e9:0e:6d:21:84', - 'version': 1, - }), - 'title': 'Tailwind iQ3', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index d2d15172718..ca6fbacf0fc 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -25,20 +25,17 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("mock_tailwind") -async def test_user_flow( - hass: HomeAssistant, - snapshot: SnapshotAssertion, -) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test the full happy path user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.1", @@ -46,8 +43,15 @@ async def test_user_flow( }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + } + assert not config_entry.options @pytest.mark.parametrize( @@ -76,19 +80,27 @@ async def test_user_flow_errors( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == expected_error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == expected_error mock_tailwind.status.side_effect = None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.2", CONF_TOKEN: "123456", }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.2", + CONF_TOKEN: "123456", + } + assert not config_entry.options async def test_user_flow_unsupported_firmware_version( @@ -105,8 +117,8 @@ async def test_user_flow_unsupported_firmware_version( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "unsupported_firmware" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_firmware" @pytest.mark.usefixtures("mock_tailwind") @@ -129,8 +141,8 @@ async def test_user_flow_already_configured( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" assert mock_config_entry.data[CONF_TOKEN] == "987654" @@ -160,19 +172,26 @@ async def test_zeroconf_flow( ), ) - assert result.get("step_id") == "zeroconf_confirm" - assert result.get("type") is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 assert progress[0].get("flow_id") == result["flow_id"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_TOKEN: "987654"} ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + } + assert not config_entry.options @pytest.mark.parametrize( @@ -200,8 +219,8 @@ async def test_zeroconf_flow_abort_incompatible_properties( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == expected_reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason @pytest.mark.parametrize( @@ -240,25 +259,33 @@ async def test_zeroconf_flow_errors( ), ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "zeroconf_confirm" - assert result2.get("errors") == expected_error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["errors"] == expected_error mock_tailwind.status.side_effect = None - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "123456", + } + assert not config_entry.options @pytest.mark.usefixtures("mock_tailwind") @@ -292,8 +319,8 @@ async def test_zeroconf_flow_not_discovered_again( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" @@ -307,17 +334,17 @@ async def test_reauth_flow( assert mock_config_entry.data[CONF_TOKEN] == "123456" result = await mock_config_entry.start_reauth_flow(hass) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "987654"}, ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_TOKEN] == "987654" @@ -343,27 +370,27 @@ async def test_reauth_flow_errors( result = await mock_config_entry.start_reauth_flow(hass) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "reauth_confirm" - assert result2.get("errors") == expected_error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == expected_error mock_tailwind.status.side_effect = None - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result3.get("type") is FlowResultType.ABORT - assert result3.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_dhcp_discovery_updates_entry( @@ -384,8 +411,8 @@ async def test_dhcp_discovery_updates_entry( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" @@ -404,5 +431,5 @@ async def test_dhcp_discovery_ignores_unknown(hass: HomeAssistant) -> None: ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "unknown" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py index 8ea5f1108f4..8e075a26279 100644 --- a/tests/components/tailwind/test_init.py +++ b/tests/components/tailwind/test_init.py @@ -66,8 +66,8 @@ async def test_config_entry_authentication_failed( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" - assert flow.get("handler") == DOMAIN + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index 3180c7c0b1d..b5b33d7c246 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index f5802c509bf..4f4daee1301 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1108,7 +1108,7 @@ async def test_sending_mqtt_commands_rgbww( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.tasmota_test", color_temp=200) + await common.async_turn_on(hass, "light.tasmota_test", color_temp_kelvin=5000) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;CT 200", @@ -1350,7 +1350,9 @@ async def test_transition( assert state.attributes.get("color_temp") == 153 # Set color_temp of the light from 153 to 500 @ 50%: Speed should be 6*2*2=24 - await common.async_turn_on(hass, "light.tasmota_test", color_temp=500, transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", color_temp_kelvin=2000, transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 ON;NoDelay;CT 500", @@ -1369,7 +1371,9 @@ async def test_transition( assert state.attributes.get("color_temp") == 500 # Set color_temp of the light from 500 to 326 @ 50%: Speed should be 6*2*2*2=48->40 - await common.async_turn_on(hass, "light.tasmota_test", color_temp=326, transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", color_temp_kelvin=3067, transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Power1 ON;NoDelay;CT 326", diff --git a/tests/components/tedee/__init__.py b/tests/components/tedee/__init__.py index a72b1fbdd6a..0bff030d2df 100644 --- a/tests/components/tedee/__init__.py +++ b/tests/components/tedee/__init__.py @@ -1 +1,14 @@ """Add tests for Tedee components.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the acaia integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 8e028cb5300..d659560ee61 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -14,6 +14,8 @@ from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry, load_fixture WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" @@ -84,8 +86,6 @@ async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock ) -> MockConfigEntry: """Set up the Tedee integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) return mock_config_entry diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index 385e4ac9bc1..e3238dacda1 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensors[entry-charging] +# name: test_binary_sensors[binary_sensor.lock_1a2b_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[entry-lock_uncalibrated] +# name: test_binary_sensors[binary_sensor.lock_1a2b_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Lock-1A2B Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_1a2b_lock_uncalibrated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -65,7 +79,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[entry-pullspring_enabled] +# name: test_binary_sensors[binary_sensor.lock_1a2b_lock_uncalibrated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Lock-1A2B Lock uncalibrated', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_lock_uncalibrated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_1a2b_pullspring_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,7 +126,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[entry-semi_locked] +# name: test_binary_sensors[binary_sensor.lock_1a2b_pullspring_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B Pullspring enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_1a2b_semi_locked-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -131,48 +172,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[state-charging] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'Lock-1A2B Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.lock_1a2b_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[state-lock_uncalibrated] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Lock-1A2B Lock uncalibrated', - }), - 'context': , - 'entity_id': 'binary_sensor.lock_1a2b_lock_uncalibrated', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[state-pullspring_enabled] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Lock-1A2B Pullspring enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_binary_sensors[state-semi_locked] +# name: test_binary_sensors[binary_sensor.lock_1a2b_semi_locked-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Lock-1A2B Semi locked', @@ -185,3 +185,189 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Lock-2C3D Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_lock_uncalibrated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_lock_uncalibrated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock uncalibrated', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uncalibrated', + 'unique_id': '98765-uncalibrated', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_lock_uncalibrated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Lock-2C3D Lock uncalibrated', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_lock_uncalibrated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_pullspring_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_pullspring_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pullspring enabled', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_enabled', + 'unique_id': '98765-pullspring_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_pullspring_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D Pullspring enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_pullspring_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_semi_locked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_semi_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Semi locked', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'semi_locked', + 'unique_id': '98765-semi_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_semi_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D Semi locked', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_semi_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 20d6bfcdc2a..af559f561b2 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -31,3 +31,35 @@ 'via_device_id': None, }) # --- +# name: test_lock_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '12345', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tedee', + 'model': 'Tedee PRO', + 'model_id': 'Tedee PRO', + 'name': 'Lock-1A2B', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index 3eba6f3f0af..cca988663d2 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -1,83 +1,4 @@ # serializer version: 1 -# name: test_lock - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Lock-1A2B', - 'supported_features': , - }), - 'context': , - 'entity_id': 'lock.lock_1a2b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unlocked', - }) -# --- -# name: test_lock.1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'lock', - 'entity_category': None, - 'entity_id': 'lock.lock_1a2b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'tedee', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '12345-lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_lock.2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'tedee', - '12345', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Tedee', - 'model': 'Tedee PRO', - 'model_id': 'Tedee PRO', - 'name': 'Lock-1A2B', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }) -# --- # name: test_lock_without_pullspring StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -157,3 +78,97 @@ 'via_device_id': , }) # --- +# name: test_locks[lock.lock_1a2b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_1a2b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.lock_1a2b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_1a2b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_locks[lock.lock_2c3d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_2c3d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.lock_2c3d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_2c3d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr index d5f4c8361c3..297fe9b0d37 100644 --- a/tests/components/tedee/snapshots/test_sensor.ambr +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[entry-battery] +# name: test_sensors[sensor.lock_1a2b_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[entry-pullspring_duration] +# name: test_sensors[sensor.lock_1a2b_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Lock-1A2B Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lock_1a2b_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[sensor.lock_1a2b_pullspring_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -69,23 +85,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[state-battery] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Lock-1A2B Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.lock_1a2b_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '70', - }) -# --- -# name: test_sensors[state-pullspring_duration] +# name: test_sensors[sensor.lock_1a2b_pullspring_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -101,3 +101,105 @@ 'state': '2', }) # --- +# name: test_sensors[sensor.lock_2c3d_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lock_2c3d_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-battery_sensor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.lock_2c3d_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Lock-2C3D Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lock_2c3d_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[sensor.lock_2c3d_pullspring_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lock_2c3d_pullspring_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pullspring duration', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_duration', + 'unique_id': '98765-pullspring_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.lock_2c3d_pullspring_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Lock-2C3D Pullspring duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lock_2c3d_pullspring_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index dfe70e7a2ea..ccfd12440ea 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -1,19 +1,20 @@ """Tests for the Tedee Binary Sensors.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed +from . import setup_integration -pytestmark = pytest.mark.usefixtures("init_integration") +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform BINARY_SENSORS = ("charging", "semi_locked", "pullspring_enabled", "lock_uncalibrated") @@ -22,21 +23,19 @@ BINARY_SENSORS = ("charging", "semi_locked", "pullspring_enabled", "lock_uncalib async def test_binary_sensors( hass: HomeAssistant, mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test tedee binary sensor.""" - for key in BINARY_SENSORS: - state = hass.states.get(f"binary_sensor.lock_1a2b_{key}") - assert state - assert state == snapshot(name=f"state-{key}") + with patch("homeassistant.components.tedee.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot(name=f"entry-{key}") + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("init_integration") async def test_new_binary_sensors( hass: HomeAssistant, mock_tedee: MagicMock, diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 63701bb1788..71bf5262f00 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -20,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import setup_integration from .conftest import WEBHOOK_ID from tests.common import MockConfigEntry @@ -32,9 +33,7 @@ async def test_load_unload_config_entry( mock_tedee: MagicMock, ) -> None: """Test loading and unloading the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -56,9 +55,7 @@ async def test_config_entry_not_ready( """Test the Tedee configuration entry not ready.""" mock_tedee.get_locks.side_effect = side_effect - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert len(mock_tedee.get_locks.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -70,9 +67,7 @@ async def test_cleanup_on_shutdown( mock_tedee: MagicMock, ) -> None: """Test the webhook is cleaned up on shutdown.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -88,9 +83,7 @@ async def test_webhook_cleanup_errors( caplog: pytest.LogCaptureFixture, ) -> None: """Test the webhook is cleaned up on shutdown.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -110,9 +103,7 @@ async def test_webhook_registration_errors( ) -> None: """Test the webhook is cleaned up on shutdown.""" mock_tedee.register_webhook.side_effect = TedeeWebhookException("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -128,9 +119,7 @@ async def test_webhook_registration_cleanup_errors( ) -> None: """Test the errors during webhook cleanup during registration.""" mock_tedee.cleanup_webhooks_by_host.side_effect = TedeeWebhookException("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -138,6 +127,21 @@ async def test_webhook_registration_cleanup_errors( assert "Failed to cleanup Tedee webhooks by host:" in caplog.text +async def test_lock_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the lock device is registered.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(mock_config_entry.domain, "12345")}) + assert device + assert device == snapshot + + async def test_bridge_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -146,9 +150,7 @@ async def test_bridge_device( snapshot: SnapshotAssertion, ) -> None: """Ensure the bridge device is registered.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) device = device_registry.async_get_device( {(mock_config_entry.domain, mock_tedee.get_local_bridge.return_value.serial)} @@ -192,9 +194,7 @@ async def test_webhook_post( ) -> None: """Test webhook callback.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) @@ -241,9 +241,7 @@ async def test_migration( "homeassistant.components.tedee.webhook_generate_id", return_value=WEBHOOK_ID, ): - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.version == 1 assert mock_config_entry.minor_version == 2 diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index d84acb212ea..e0fe9673a46 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -1,7 +1,7 @@ """Tests for tedee lock.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from urllib.parse import urlparse from aiotedee import TedeeLock, TedeeLockState @@ -22,43 +22,44 @@ from homeassistant.components.lock import ( LockState, ) from homeassistant.components.webhook import async_generate_url -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from . import setup_integration from .conftest import WEBHOOK_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("init_integration") - -async def test_lock( +async def test_locks( hass: HomeAssistant, mock_tedee: MagicMock, - device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, +) -> None: + """Test tedee locks.""" + with patch("homeassistant.components.tedee.PLATFORMS", [Platform.LOCK]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_lock_service_calls( + hass: HomeAssistant, + mock_tedee: MagicMock, ) -> None: """Test the tedee lock.""" - mock_tedee.lock.return_value = None - mock_tedee.unlock.return_value = None - mock_tedee.open.return_value = None - - state = hass.states.get("lock.lock_1a2b") - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot - assert entry.device_id - - device = device_registry.async_get(entry.device_id) - assert device == snapshot await hass.services.async_call( LOCK_DOMAIN, @@ -106,6 +107,7 @@ async def test_lock( assert state.state == LockState.UNLOCKING +@pytest.mark.usefixtures("init_integration") async def test_lock_without_pullspring( hass: HomeAssistant, mock_tedee: MagicMock, @@ -116,9 +118,6 @@ async def test_lock_without_pullspring( """Test the tedee lock without pullspring.""" # Fetch translations await async_setup_component(hass, "homeassistant", {}) - mock_tedee.lock.return_value = None - mock_tedee.unlock.return_value = None - mock_tedee.open.return_value = None state = hass.states.get("lock.lock_2c3d") assert state @@ -149,6 +148,7 @@ async def test_lock_without_pullspring( assert len(mock_tedee.open.mock_calls) == 0 +@pytest.mark.usefixtures("init_integration") async def test_lock_errors( hass: HomeAssistant, mock_tedee: MagicMock, @@ -191,6 +191,7 @@ async def test_lock_errors( assert exc_info.value.translation_key == "open_failed" +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "side_effect", [ @@ -217,6 +218,7 @@ async def test_update_failed( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("init_integration") async def test_cleanup_removed_locks( hass: HomeAssistant, mock_tedee: MagicMock, @@ -247,6 +249,7 @@ async def test_cleanup_removed_locks( assert "Lock-1A2B" not in locks +@pytest.mark.usefixtures("init_integration") async def test_new_lock( hass: HomeAssistant, mock_tedee: MagicMock, @@ -275,6 +278,7 @@ async def test_new_lock( assert state +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("lib_state", "expected_state"), [ diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index ddbcd5086af..3c03d340100 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -1,20 +1,20 @@ """Tests for the Tedee Sensors.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed - -pytestmark = pytest.mark.usefixtures("init_integration") +from . import setup_integration +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform SENSORS = ( "battery", @@ -25,21 +25,18 @@ SENSORS = ( async def test_sensors( hass: HomeAssistant, mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test tedee sensors.""" - for key in SENSORS: - state = hass.states.get(f"sensor.lock_1a2b_{key}") - assert state - assert state == snapshot(name=f"state-{key}") + with patch("homeassistant.components.tedee.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry.device_id - assert entry == snapshot(name=f"entry-{key}") + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("init_integration") async def test_new_sensors( hass: HomeAssistant, mock_tedee: MagicMock, diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index e0d95ff968d..2c9b81e7c91 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -804,7 +804,7 @@ EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of tem ), "unit_of_measurement": ( "'None' is not a valid unit for device class 'energy'; " - "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'TWh', 'Wh'" + "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'mWh', 'TWh', 'Wh'" ), }, ), diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 065a1488dc9..b5ba93a4bd0 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -773,7 +773,7 @@ async def test_temperature_action_no_template( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP: 345}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP_KELVIN: 2898}, blocking=True, ) @@ -1395,7 +1395,7 @@ async def test_all_colors_mode_no_template( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP: 123}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP_KELVIN: 8130}, blocking=True, ) @@ -1531,7 +1531,7 @@ async def test_all_colors_mode_no_template( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP: 234}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP_KELVIN: 4273}, blocking=True, ) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ff428c5d4b4..6053a2bd9ec 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -3,14 +3,7 @@ import pytest from homeassistant import setup -from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, -) +from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError @@ -44,7 +37,7 @@ _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" }, ), ( - STATE_CLEANING, + VacuumActivity.CLEANING, 100, { "vacuum": { @@ -149,10 +142,10 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None: """Test templates with values from other entities.""" _verify(hass, STATE_UNKNOWN, None) - hass.states.async_set(_STATE_INPUT_SELECT, STATE_CLEANING) + hass.states.async_set(_STATE_INPUT_SELECT, VacuumActivity.CLEANING) hass.states.async_set(_BATTERY_LEVEL_INPUT_NUMBER, 100) await hass.async_block_till_done() - _verify(hass, STATE_CLEANING, 100) + _verify(hass, VacuumActivity.CLEANING, 100) @pytest.mark.parametrize( @@ -370,8 +363,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_CLEANING - _verify(hass, STATE_CLEANING, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.CLEANING + _verify(hass, VacuumActivity.CLEANING, None) assert len(calls) == 1 assert calls[-1].data["action"] == "start" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -381,8 +374,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_PAUSED - _verify(hass, STATE_PAUSED, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.PAUSED + _verify(hass, VacuumActivity.PAUSED, None) assert len(calls) == 2 assert calls[-1].data["action"] == "pause" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -392,8 +385,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_IDLE - _verify(hass, STATE_IDLE, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.IDLE + _verify(hass, VacuumActivity.IDLE, None) assert len(calls) == 3 assert calls[-1].data["action"] == "stop" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -403,8 +396,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_RETURNING - _verify(hass, STATE_RETURNING, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.RETURNING + _verify(hass, VacuumActivity.RETURNING, None) assert len(calls) == 4 assert calls[-1].data["action"] == "return_to_base" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -506,7 +499,11 @@ async def _register_basic_vacuum(hass: HomeAssistant) -> None: assert await setup.async_setup_component( hass, "input_select", - {"input_select": {"state": {"name": "State", "options": [STATE_CLEANING]}}}, + { + "input_select": { + "state": {"name": "State", "options": [VacuumActivity.CLEANING]} + } + }, ) with assert_setup_component(1, "vacuum"): @@ -522,7 +519,7 @@ async def _register_basic_vacuum(hass: HomeAssistant) -> None: "service": "input_select.select_option", "data": { "entity_id": _STATE_INPUT_SELECT, - "option": STATE_CLEANING, + "option": VacuumActivity.CLEANING, }, } } @@ -554,11 +551,11 @@ async def _register_components(hass: HomeAssistant) -> None: "state": { "name": "State", "options": [ - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity.CLEANING, + VacuumActivity.DOCKED, + VacuumActivity.IDLE, + VacuumActivity.PAUSED, + VacuumActivity.RETURNING, ], }, "fan_speed": { @@ -578,7 +575,7 @@ async def _register_components(hass: HomeAssistant) -> None: "service": "input_select.select_option", "data": { "entity_id": _STATE_INPUT_SELECT, - "option": STATE_CLEANING, + "option": VacuumActivity.CLEANING, }, }, { @@ -592,7 +589,10 @@ async def _register_components(hass: HomeAssistant) -> None: "pause": [ { "service": "input_select.select_option", - "data": {"entity_id": _STATE_INPUT_SELECT, "option": STATE_PAUSED}, + "data": { + "entity_id": _STATE_INPUT_SELECT, + "option": VacuumActivity.PAUSED, + }, }, { "service": "test.automation", @@ -605,7 +605,10 @@ async def _register_components(hass: HomeAssistant) -> None: "stop": [ { "service": "input_select.select_option", - "data": {"entity_id": _STATE_INPUT_SELECT, "option": STATE_IDLE}, + "data": { + "entity_id": _STATE_INPUT_SELECT, + "option": VacuumActivity.IDLE, + }, }, { "service": "test.automation", @@ -620,7 +623,7 @@ async def _register_components(hass: HomeAssistant) -> None: "service": "input_select.select_option", "data": { "entity_id": _STATE_INPUT_SELECT, - "option": STATE_RETURNING, + "option": VacuumActivity.RETURNING, }, }, { diff --git a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr index eb8c57910a4..cdb24b1d2b5 100644 --- a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr +++ b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr @@ -165,6 +165,7 @@ 'openid', 'offline_access', 'vehicle_device_data', + 'vehicle_location', 'vehicle_cmds', 'vehicle_charging_cmds', 'energy_device_data', diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index b49e090cd5d..6cb8c60ac0c 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -11,7 +11,6 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.components.tesla_fleet.const import ( AUTHORIZE_URL, - CLIENT_ID, DOMAIN, SCOPES, TOKEN_URL, @@ -52,69 +51,18 @@ async def access_token(hass: HomeAssistant) -> str: ) -@pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - access_token: str, -) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( +@pytest.fixture(autouse=True) +async def create_credential(hass: HomeAssistant) -> None: + """Create a user credential.""" + # Create user application credential + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( hass, - { - "flow_id": result["flow_id"], - "redirect_uri": REDIRECT, - }, + DOMAIN, + ClientCredential("user_client_id", "user_client_secret"), + "user_cred", ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - - assert result["url"].startswith(AUTHORIZE_URL) - parsed_url = urlparse(result["url"]) - parsed_query = parse_qs(parsed_url.query) - assert parsed_query["response_type"][0] == "code" - assert parsed_query["client_id"][0] == CLIENT_ID - assert parsed_query["redirect_uri"][0] == REDIRECT - assert parsed_query["state"][0] == state - assert parsed_query["scope"][0] == " ".join(SCOPES) - assert parsed_query["code_challenge"][0] is not None - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.clear_requests() - aioclient_mock.post( - TOKEN_URL, - json={ - "refresh_token": "mock-refresh-token", - "access_token": access_token, - "type": "Bearer", - "expires_in": 60, - }, - ) - with patch( - "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == UNIQUE_ID - assert "result" in result - assert result["result"].unique_id == UNIQUE_ID - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == access_token - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" - @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow_user_cred( @@ -125,24 +73,10 @@ async def test_full_flow_user_cred( ) -> None: """Check full flow.""" - # Create user application credential - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential("user_client_id", "user_client_secret"), - "user_cred", - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"implementation": "user_cred"} - ) assert result["type"] is FlowResultType.EXTERNAL_STEP state = config_entry_oauth2_flow._encode_jwt( diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index e459379ccf7..bf483d576cd 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -12,6 +12,8 @@ WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} PRODUCTS = load_json_object_fixture("products.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) +VEHICLE_DATA_ASLEEP = load_json_object_fixture("vehicle_data.json", DOMAIN) +VEHICLE_DATA_ASLEEP["response"]["state"] = TeslemetryState.OFFLINE VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json index 8da921a33f4..56497a6d936 100644 --- a/tests/components/teslemetry/fixtures/products.json +++ b/tests/components/teslemetry/fixtures/products.json @@ -4,7 +4,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "LRWXF7EK4KC700000", + "vin": "LRW3F7EK4NC700000", "color": null, "access_type": "OWNER", "display_name": "Test", diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index d99bc8de5a8..fcfa0707b2c 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -3,7 +3,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "LRWXF7EK4KC700000", + "vin": "LRW3F7EK4NC700000", "color": null, "access_type": "OWNER", "granular_access": { diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 76416982eba..5ef5ea92a74 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -3,7 +3,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "LRWXF7EK4KC700000", + "vin": "LRW3F7EK4NC700000", "color": null, "access_type": "OWNER", "granular_access": { @@ -24,7 +24,6 @@ "battery_range": 266.87, "charge_amps": 16, "charge_current_request": 16, - "charge_current_request_max": 16, "charge_enable_request": true, "charge_energy_added": 0, "charge_limit_soc": 80, @@ -72,16 +71,16 @@ "user_charge_enable_request": true }, "climate_state": { - "allow_cabin_overheat_protection": true, + "allow_cabin_overheat_protection": null, "auto_seat_climate_left": false, "auto_seat_climate_right": false, "auto_steering_wheel_heat": false, "battery_heater": true, "battery_heater_no_power": null, - "cabin_overheat_protection": "Off", + "cabin_overheat_protection": null, "cabin_overheat_protection_actively_cooling": false, "climate_keeper_mode": "off", - "cop_activation_temperature": "Low", + "cop_activation_temperature": null, "defrost_mode": 0, "driver_temp_setting": 22, "fan_status": 0, @@ -106,7 +105,7 @@ "seat_heater_right": 0, "side_mirror_heaters": false, "steering_wheel_heat_level": 0, - "steering_wheel_heater": false, + "steering_wheel_heater": true, "supports_fan_only_cabin_overheat_protection": true, "timestamp": 1705707520649, "wiper_blade_heater": false @@ -204,9 +203,9 @@ "is_user_present": true, "locked": false, "media_info": { - "audio_volume": 2.6667, - "audio_volume_increment": 0.333333, - "audio_volume_max": 10.333333, + "audio_volume": null, + "audio_volume_increment": null, + "audio_volume_max": null, "media_playback_status": "Stopped", "now_playing_album": "", "now_playing_artist": "", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr b/tests/components/teslemetry/snapshots/test_binary_sensors.ambr index 383db58b336..95330840109 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensors.ambr @@ -212,7 +212,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_heater_on', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_heater_on', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_heater_on', 'unit_of_measurement': None, }) # --- @@ -259,7 +259,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection_actively_cooling', 'unit_of_measurement': None, }) # --- @@ -306,7 +306,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', 'unit_of_measurement': None, }) # --- @@ -353,7 +353,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_phases', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_phases', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_phases', 'unit_of_measurement': None, }) # --- @@ -399,7 +399,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dashcam_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dashcam_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dashcam_state', 'unit_of_measurement': None, }) # --- @@ -446,7 +446,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_df', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_df', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_df', 'unit_of_measurement': None, }) # --- @@ -493,7 +493,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fd_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fd_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fd_window', 'unit_of_measurement': None, }) # --- @@ -540,7 +540,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pf', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pf', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pf', 'unit_of_measurement': None, }) # --- @@ -587,7 +587,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fp_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fp_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fp_window', 'unit_of_measurement': None, }) # --- @@ -634,7 +634,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_is_preconditioning', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_is_preconditioning', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_preconditioning', 'unit_of_measurement': None, }) # --- @@ -680,7 +680,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_preconditioning_enabled', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_preconditioning_enabled', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_preconditioning_enabled', 'unit_of_measurement': None, }) # --- @@ -726,7 +726,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dr', 'unit_of_measurement': None, }) # --- @@ -773,7 +773,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rd_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rd_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rd_window', 'unit_of_measurement': None, }) # --- @@ -820,7 +820,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pr', 'unit_of_measurement': None, }) # --- @@ -867,7 +867,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rp_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rp_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rp_window', 'unit_of_measurement': None, }) # --- @@ -914,7 +914,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_scheduled_charging_pending', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_scheduled_charging_pending', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_scheduled_charging_pending', 'unit_of_measurement': None, }) # --- @@ -960,7 +960,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'state', - 'unique_id': 'LRWXF7EK4KC700000-state', + 'unique_id': 'LRW3F7EK4NC700000-state', 'unit_of_measurement': None, }) # --- @@ -1007,7 +1007,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fl', 'unit_of_measurement': None, }) # --- @@ -1054,7 +1054,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fr', 'unit_of_measurement': None, }) # --- @@ -1101,7 +1101,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_rl', 'unit_of_measurement': None, }) # --- @@ -1148,7 +1148,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_rr', 'unit_of_measurement': None, }) # --- @@ -1195,7 +1195,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_trip_charging', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_trip_charging', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_trip_charging', 'unit_of_measurement': None, }) # --- @@ -1241,7 +1241,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_is_user_present', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_is_user_present', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_is_user_present', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr index 84cf4c21078..6d3016186ae 100644 --- a/tests/components/teslemetry/snapshots/test_button.ambr +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'flash_lights', - 'unique_id': 'LRWXF7EK4KC700000-flash_lights', + 'unique_id': 'LRW3F7EK4NC700000-flash_lights', 'unit_of_measurement': None, }) # --- @@ -74,7 +74,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'homelink', - 'unique_id': 'LRWXF7EK4KC700000-homelink', + 'unique_id': 'LRW3F7EK4NC700000-homelink', 'unit_of_measurement': None, }) # --- @@ -120,7 +120,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'honk', - 'unique_id': 'LRWXF7EK4KC700000-honk', + 'unique_id': 'LRW3F7EK4NC700000-honk', 'unit_of_measurement': None, }) # --- @@ -166,7 +166,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'enable_keyless_driving', - 'unique_id': 'LRWXF7EK4KC700000-enable_keyless_driving', + 'unique_id': 'LRW3F7EK4NC700000-enable_keyless_driving', 'unit_of_measurement': None, }) # --- @@ -212,7 +212,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'boombox', - 'unique_id': 'LRWXF7EK4KC700000-boombox', + 'unique_id': 'LRW3F7EK4NC700000-boombox', 'unit_of_measurement': None, }) # --- @@ -258,7 +258,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wake', - 'unique_id': 'LRWXF7EK4KC700000-wake', + 'unique_id': 'LRW3F7EK4NC700000-wake', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 9d5e3827ffc..7064309e98b 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -43,7 +43,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', 'unit_of_measurement': None, }) # --- @@ -113,7 +113,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, }) # --- @@ -184,7 +184,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', 'unit_of_measurement': None, }) # --- @@ -208,7 +208,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_climate_alt[climate.test_climate-entry] @@ -253,7 +253,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, }) # --- @@ -322,7 +322,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', 'unit_of_measurement': None, }) # --- @@ -361,150 +361,10 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, }) # --- -# name: test_climate_offline[climate.test_cabin_overheat_protection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': 40, - 'min_temp': 30, - 'target_temp_step': 5, - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.test_cabin_overheat_protection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Cabin overheat protection', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate_offline[climate.test_cabin_overheat_protection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': None, - 'friendly_name': 'Test Cabin overheat protection', - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': 40, - 'min_temp': 30, - 'supported_features': , - 'target_temp_step': 5, - }), - 'context': , - 'entity_id': 'climate.test_cabin_overheat_protection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_climate_offline[climate.test_climate-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - ]), - 'max_temp': 28.0, - 'min_temp': 15.0, - 'preset_modes': list([ - 'off', - 'keep', - 'dog', - 'camp', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.test_climate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Climate', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate_offline[climate.test_climate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': None, - 'friendly_name': 'Test Climate', - 'hvac_modes': list([ - , - , - ]), - 'max_temp': 28.0, - 'min_temp': 15.0, - 'preset_mode': None, - 'preset_modes': list([ - 'off', - 'keep', - 'dog', - 'camp', - ]), - 'supported_features': , - 'temperature': None, - }), - 'context': , - 'entity_id': 'climate.test_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_invalid_error[error] 'Command returned exception: The data request or command is unknown.' # --- diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 7ffb9c4a1f9..24e1b02a5f8 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', 'unit_of_measurement': None, }) # --- @@ -124,7 +124,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', 'unit_of_measurement': None, }) # --- @@ -172,7 +172,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', 'unit_of_measurement': None, }) # --- @@ -220,7 +220,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'windows', - 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', 'unit_of_measurement': None, }) # --- @@ -268,7 +268,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', 'unit_of_measurement': None, }) # --- @@ -316,7 +316,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', 'unit_of_measurement': None, }) # --- @@ -364,7 +364,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', 'unit_of_measurement': None, }) # --- @@ -412,7 +412,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', 'unit_of_measurement': None, }) # --- @@ -460,7 +460,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'windows', - 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', 'unit_of_measurement': None, }) # --- @@ -508,7 +508,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_door_open', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', 'unit_of_measurement': None, }) # --- @@ -556,7 +556,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_ft', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', 'unit_of_measurement': None, }) # --- @@ -604,7 +604,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', 'unit_of_measurement': None, }) # --- @@ -652,7 +652,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rt', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', 'unit_of_measurement': None, }) # --- @@ -700,7 +700,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'windows', - 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 6c18cdf75c6..ac4c388873f 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', - 'unique_id': 'LRWXF7EK4KC700000-location', + 'unique_id': 'LRW3F7EK4NC700000-location', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'route', - 'unique_id': 'LRWXF7EK4KC700000-route', + 'unique_id': 'LRW3F7EK4NC700000-route', 'unit_of_measurement': None, }) # --- @@ -99,3 +99,37 @@ 'state': 'home', }) # --- +# name: test_device_tracker_alt[device_tracker.test_location-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Location', + 'gps_accuracy': 0, + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker_alt[device_tracker.test_route-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Route', + 'gps_accuracy': 0, + 'latitude': 30.2226265, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index e07f075b7d8..7d60ed82859 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -31,7 +31,7 @@ 'via_device_id': None, }) # --- -# name: test_devices[{('teslemetry', 'LRWXF7EK4KC700000')}] +# name: test_devices[{('teslemetry', 'LRW3F7EK4NC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -45,19 +45,19 @@ 'identifiers': set({ tuple( 'teslemetry', - 'LRWXF7EK4KC700000', + 'LRW3F7EK4NC700000', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', - 'model': 'Model X', + 'model': 'Model 3', 'model_id': None, 'name': 'Test', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': 'LRWXF7EK4KC700000', + 'serial_number': 'LRW3F7EK4NC700000', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index deaabbae904..2130c4d9574 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_latch', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_latch', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', 'unit_of_measurement': None, }) # --- @@ -75,7 +75,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_locked', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_locked', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 06500437701..a9d2569c637 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'media', - 'unique_id': 'LRWXF7EK4KC700000-media', + 'unique_id': 'LRW3F7EK4NC700000-media', 'unit_of_measurement': None, }) # --- @@ -67,7 +67,6 @@ 'media_title': '', 'source': 'Spotify', 'supported_features': , - 'volume_level': 0.25806775026025003, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -107,7 +106,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media', - 'unique_id': 'LRWXF7EK4KC700000-media', + 'unique_id': 'LRW3F7EK4NC700000-media', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index f33b5e15d30..0f30daf635e 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_current_request', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_current_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_current_request', 'unit_of_measurement': , }) # --- @@ -206,7 +206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_limit_soc', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_limit_soc', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_limit_soc', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 4e6feda7e5d..0c2547f309d 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_left', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_left', 'unit_of_measurement': None, }) # --- @@ -208,7 +208,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_right', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_right', 'unit_of_measurement': None, }) # --- @@ -267,7 +267,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_center', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_center', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_center', 'unit_of_measurement': None, }) # --- @@ -326,7 +326,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_left', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_left', 'unit_of_measurement': None, }) # --- @@ -385,7 +385,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_right', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_right', 'unit_of_measurement': None, }) # --- @@ -408,178 +408,3 @@ 'state': 'off', }) # --- -# name: test_select[select.test_seat_heater_third_row_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.test_seat_heater_third_row_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Seat heater third row left', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_seat_heater_third_row_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_left', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[select.test_seat_heater_third_row_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat heater third row left', - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.test_seat_heater_third_row_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_select[select.test_seat_heater_third_row_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.test_seat_heater_third_row_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Seat heater third row right', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_seat_heater_third_row_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_right', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[select.test_seat_heater_third_row_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat heater third row right', - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.test_seat_heater_third_row_right', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_select[select.test_steering_wheel_heater-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'off', - 'low', - 'high', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.test_steering_wheel_heater', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Steering wheel heater', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_steering_wheel_heat_level', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_steering_wheel_heat_level', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[select.test_steering_wheel_heater-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Steering wheel heater', - 'options': list([ - 'off', - 'low', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.test_steering_wheel_heater', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 96cebc2b01f..acff157bfea 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -2422,7 +2422,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_level', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_level', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_level', 'unit_of_measurement': '%', }) # --- @@ -2495,7 +2495,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_range', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_range', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_range', 'unit_of_measurement': , }) # --- @@ -2560,7 +2560,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', 'unit_of_measurement': None, }) # --- @@ -2624,7 +2624,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_energy_added', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_energy_added', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_energy_added', 'unit_of_measurement': , }) # --- @@ -2694,7 +2694,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_rate', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_rate', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_rate', 'unit_of_measurement': , }) # --- @@ -2761,7 +2761,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_actual_current', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_actual_current', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_actual_current', 'unit_of_measurement': , }) # --- @@ -2828,7 +2828,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_power', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_power', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_power', 'unit_of_measurement': , }) # --- @@ -2895,7 +2895,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_voltage', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_voltage', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_voltage', 'unit_of_measurement': , }) # --- @@ -2969,7 +2969,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charging_state', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charging_state', 'unit_of_measurement': None, }) # --- @@ -3051,7 +3051,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_miles_to_arrival', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_miles_to_arrival', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_miles_to_arrival', 'unit_of_measurement': , }) # --- @@ -3121,7 +3121,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_driver_temp_setting', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_driver_temp_setting', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_driver_temp_setting', 'unit_of_measurement': , }) # --- @@ -3194,7 +3194,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_est_battery_range', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_est_battery_range', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_est_battery_range', 'unit_of_measurement': , }) # --- @@ -3259,7 +3259,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_fast_charger_type', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_fast_charger_type', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_fast_charger_type', 'unit_of_measurement': None, }) # --- @@ -3326,7 +3326,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_ideal_battery_range', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_ideal_battery_range', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_ideal_battery_range', 'unit_of_measurement': , }) # --- @@ -3396,7 +3396,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_inside_temp', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_inside_temp', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_inside_temp', 'unit_of_measurement': , }) # --- @@ -3469,7 +3469,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_odometer', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_odometer', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_odometer', 'unit_of_measurement': , }) # --- @@ -3539,7 +3539,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_outside_temp', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_outside_temp', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_outside_temp', 'unit_of_measurement': , }) # --- @@ -3609,7 +3609,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_passenger_temp_setting', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_passenger_temp_setting', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_passenger_temp_setting', 'unit_of_measurement': , }) # --- @@ -3676,7 +3676,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_power', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_power', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_power', 'unit_of_measurement': , }) # --- @@ -3748,7 +3748,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_shift_state', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_shift_state', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_shift_state', 'unit_of_measurement': None, }) # --- @@ -3826,7 +3826,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_speed', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_speed', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_speed', 'unit_of_measurement': , }) # --- @@ -3893,7 +3893,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_energy_at_arrival', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_energy_at_arrival', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_energy_at_arrival', 'unit_of_measurement': '%', }) # --- @@ -3958,7 +3958,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_minutes_to_arrival', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_minutes_to_arrival', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_minutes_to_arrival', 'unit_of_measurement': None, }) # --- @@ -4019,7 +4019,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_minutes_to_full_charge', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_minutes_to_full_charge', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_minutes_to_full_charge', 'unit_of_measurement': None, }) # --- @@ -4088,7 +4088,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_fl', 'unit_of_measurement': , }) # --- @@ -4161,7 +4161,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_fr', 'unit_of_measurement': , }) # --- @@ -4234,7 +4234,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_rl', 'unit_of_measurement': , }) # --- @@ -4307,7 +4307,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_rr', 'unit_of_measurement': , }) # --- @@ -4374,7 +4374,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_traffic_minutes_delay', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_traffic_minutes_delay', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_traffic_minutes_delay', 'unit_of_measurement': , }) # --- @@ -4441,7 +4441,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_usable_battery_level', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_usable_battery_level', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_usable_battery_level', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index f55cbae6a54..5693d4bdd5e 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -122,7 +122,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_left', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_seat_climate_left', 'unit_of_measurement': None, }) # --- @@ -169,7 +169,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_right', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_seat_climate_right', 'unit_of_measurement': None, }) # --- @@ -216,7 +216,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_steering_wheel_heat', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_steering_wheel_heat', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_steering_wheel_heat', 'unit_of_measurement': None, }) # --- @@ -263,7 +263,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_user_charge_enable_request', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) # --- @@ -310,7 +310,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_defrost_mode', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_defrost_mode', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_defrost_mode', 'unit_of_measurement': None, }) # --- @@ -357,7 +357,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sentry_mode', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sentry_mode', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sentry_mode', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index a1213f3d94b..0777f4ccdb9 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_software_update_status', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_software_update_status', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_software_update_status', 'unit_of_measurement': None, }) # --- @@ -86,7 +86,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_software_update_status', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_software_update_status', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_software_update_status', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/test_binary_sensors.py b/tests/components/teslemetry/test_binary_sensors.py index 95fccde5f25..0a47dce9537 100644 --- a/tests/components/teslemetry/test_binary_sensors.py +++ b/tests/components/teslemetry/test_binary_sensors.py @@ -5,10 +5,9 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -49,15 +48,3 @@ async def test_binary_sensor_refresh( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) - - -async def test_binary_sensor_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the binary sensor entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.BINARY_SENSOR]) - state = hass.states.get("binary_sensor.test_status") - assert state.state == STATE_UNKNOWN diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 55f99caa13c..33f2e134806 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline +from tesla_fleet_api.exceptions import InvalidCommand from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -19,7 +19,6 @@ from homeassistant.components.climate import ( SERVICE_TURN_ON, HVACMode, ) -from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -31,12 +30,11 @@ from .const import ( COMMAND_IGNORED_REASON, METADATA_NOSCOPE, VEHICLE_DATA_ALT, + VEHICLE_DATA_ASLEEP, WAKE_UP_ASLEEP, WAKE_UP_ONLINE, ) -from tests.common import async_fire_time_changed - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate( @@ -205,20 +203,6 @@ async def test_climate_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_climate_offline( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the climate entity is correct.""" - - mock_vehicle_data.side_effect = VehicleOffline - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert_entities(hass, entry.entry_id, entity_registry, snapshot) - - async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Tests service error is handled.""" @@ -296,18 +280,9 @@ async def test_asleep_or_offline( ) -> None: """Tests asleep is handled.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" - mock_vehicle_data.assert_called_once() - - # Put the vehicle alseep - mock_vehicle_data.reset_mock() - mock_vehicle_data.side_effect = VehicleOffline - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - mock_vehicle_data.assert_called_once() - mock_wake_up.reset_mock() # Run a command but fail trying to wake up the vehicle mock_wake_up.side_effect = InvalidCommand diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 5801a356ac5..7dbdcfa5747 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -13,7 +12,7 @@ from homeassistant.components.cover import ( SERVICE_STOP_COVER, CoverState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -61,18 +60,6 @@ async def test_cover_noscope( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_cover_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the cover entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.COVER]) - state = hass.states.get("cover.test_windows") - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index a3fcd428c66..d86c3ca8596 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -1,13 +1,15 @@ """Test the Teslemetry device tracker platform.""" -from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline +from unittest.mock import AsyncMock -from homeassistant.const import STATE_UNKNOWN, Platform +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 assert_entities, setup_platform +from . import assert_entities, assert_entities_alt, setup_platform +from .const import VEHICLE_DATA_ALT async def test_device_tracker( @@ -21,13 +23,14 @@ async def test_device_tracker( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_device_tracker_offline( +async def test_device_tracker_alt( hass: HomeAssistant, - mock_vehicle_data, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, ) -> None: - """Tests that the device tracker entities are correct when offline.""" + """Tests that the device tracker entities are correct.""" - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.DEVICE_TRACKER]) - state = hass.states.get("device_tracker.test_location") - assert state.state == STATE_UNKNOWN + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 2a33e1def66..6d4e04c21b4 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -12,10 +12,7 @@ from tesla_fleet_api.exceptions import ( VehicleOffline, ) -from homeassistant.components.teslemetry.coordinator import ( - VEHICLE_INTERVAL, - VEHICLE_WAIT, -) +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform @@ -23,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import VEHICLE_DATA_ALT, WAKE_UP_ASLEEP +from .const import VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -72,22 +69,6 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -# Vehicle Coordinator -async def test_vehicle_refresh_asleep( - hass: HomeAssistant, - mock_vehicle: AsyncMock, - mock_vehicle_data: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test coordinator refresh with an error.""" - - mock_vehicle.return_value = WAKE_UP_ASLEEP - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert entry.state is ConfigEntryState.LOADED - mock_vehicle.assert_called_once() - mock_vehicle_data.assert_not_called() - - async def test_vehicle_refresh_offline( hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory ) -> None: @@ -117,63 +98,6 @@ async def test_vehicle_refresh_error( assert entry.state is state -async def test_vehicle_sleep( - hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory -) -> None: - """Test coordinator refresh with an error.""" - await setup_platform(hass, [Platform.CLIMATE]) - assert mock_vehicle_data.call_count == 1 - - freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Let vehicle sleep, no updates for 15 minutes - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 - - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Vehicle didn't sleep, go back to normal - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 3 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Regular polling - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 4 - - mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Vehicle active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 5 - - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 6 - - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 7 - - # Test Energy Live Coordinator @pytest.mark.parametrize(("side_effect", "state"), ERRORS) async def test_energy_live_refresh_error( diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index b1460e870f0..f7c9fea1400 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -1,10 +1,9 @@ """Test the Teslemetry lock platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -12,7 +11,7 @@ from homeassistant.components.lock import ( SERVICE_UNLOCK, LockState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -32,18 +31,6 @@ async def test_lock( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_lock_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the lock entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.LOCK]) - state = hass.states.get("lock.test_lock") - assert state.state == STATE_UNKNOWN - - async def test_lock_services( hass: HomeAssistant, ) -> None: diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index 0d30750d10d..ae462bfd026 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, @@ -47,18 +46,6 @@ async def test_media_player_alt( assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) -async def test_media_player_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the media player entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.MEDIA_PLAYER]) - state = hass.states.get("media_player.test_media_player") - assert state.state == MediaPlayerState.OFF - - async def test_media_player_noscope( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 5df948b475c..65c03514d22 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -4,14 +4,13 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,18 +30,6 @@ async def test_number( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_number_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the number entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.NUMBER]) - state = hass.states.get("number.test_charge_current") - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_services( hass: HomeAssistant, mock_vehicle_data: AsyncMock diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index caf0b9c1deb..005a6a2004e 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.select import ( ATTR_OPTION, @@ -33,18 +32,6 @@ async def test_select( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_select_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the select entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.SELECT]) - state = hass.states.get("select.test_seat_heater_front_left") - assert state.state == STATE_UNKNOWN - - async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: """Tests that the select services work.""" mock_vehicle_data.return_value = VEHICLE_DATA_ALT @@ -112,3 +99,23 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: state = hass.states.get(entity_id) assert state.state == EnergyExportMode.BATTERY_OK.value call.assert_called_once() + + +async def test_select_invalid_data( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, +) -> None: + """Tests that the select entities handle invalid data.""" + + broken_data = VEHICLE_DATA_ALT.copy() + broken_data["response"]["climate_state"]["seat_heater_left"] = "green" + broken_data["response"]["climate_state"]["steering_wheel_heat_level"] = "yellow" + + mock_vehicle_data.return_value = broken_data + await setup_platform(hass, [Platform.SELECT]) + state = hass.states.get("select.test_seat_heater_front_left") + assert state.state == STATE_UNKNOWN + state = hass.states.get("select.test_steering_wheel_heater") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index dae3ce6fbf8..6a1ddb430ce 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -4,20 +4,13 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -49,18 +42,6 @@ async def test_switch_alt( assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) -async def test_switch_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the switch entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.SWITCH]) - state = hass.states.get("switch.test_auto_seat_climate_left") - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("name", "on", "off"), diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index f02f09cd19a..448f31afd67 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -5,12 +5,11 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,18 +43,6 @@ async def test_update_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_update_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the update entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.UPDATE]) - state = hass.states.get("update.test_update") - assert state.state == STATE_UNKNOWN - - async def test_update_services( hass: HomeAssistant, mock_vehicle_data: AsyncMock, diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 8e20af6cb7a..3764d481928 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -64,21 +64,22 @@ async def test_text_set_value(hass: HomeAssistant) -> None: with pytest.raises(ValueError): await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: ""}) + text, ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: ""}) ) with pytest.raises(ValueError): await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "hello world!"}) + text, + ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "hello world!"}), ) with pytest.raises(ValueError): await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "HELLO"}) + text, ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "HELLO"}) ) await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "test2"}) + text, ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "test2"}) ) assert text.state == "test2" diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 6998d8fbcc7..b7f4ed6b8f4 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -26,8 +26,8 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -153,8 +153,8 @@ async def test_color_light( assert attributes[ATTR_COLOR_MODE] == "brightness" else: assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_MIN_MIREDS] == 111 - assert attributes[ATTR_MAX_MIREDS] == 250 + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 assert attributes[ATTR_HS_COLOR] == (10, 30) assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) @@ -307,8 +307,8 @@ async def test_color_temp_light( assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] else: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] - assert attributes[ATTR_MIN_MIREDS] == 111 - assert attributes[ATTR_MAX_MIREDS] == 250 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 await hass.services.async_call( diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr index 11427a84801..3613f7e5997 100644 --- a/tests/components/tractive/snapshots/test_diagnostics.ambr +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr index f1ed397450e..f10cfb29226 100644 --- a/tests/components/tractive/snapshots/test_sensor.ambr +++ b/tests/components/tractive/snapshots/test_sensor.ambr @@ -139,7 +139,7 @@ 'supported_features': 0, 'translation_key': 'calories', 'unique_id': 'pet_id_123_calories', - 'unit_of_measurement': 'kcal', + 'unit_of_measurement': , }) # --- # name: test_sensor[sensor.test_pet_calories_burned-state] @@ -147,7 +147,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Pet Calories burned', 'state_class': , - 'unit_of_measurement': 'kcal', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.test_pet_calories_burned', diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py index 887b043689f..c7091e77343 100644 --- a/tests/components/tradfri/test_light.py +++ b/tests/components/tradfri/test_light.py @@ -9,10 +9,10 @@ from pytradfri.device import Device from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, ColorMode, @@ -67,9 +67,9 @@ def bulb_cws() -> str: "light.test_ws", { ATTR_BRIGHTNESS: 250, - ATTR_COLOR_TEMP: 400, - ATTR_MIN_MIREDS: 250, - ATTR_MAX_MIREDS: 454, + ATTR_COLOR_TEMP_KELVIN: 2500, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2202, ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, }, diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index a5a68a12a22..90d83d69814 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '12345', 'unique_id': '12345', 'version': 1, @@ -54,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Old Tuya configuration entry', 'unique_id': '12345', 'version': 1, @@ -107,10 +111,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'mocked_username', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'mocked_username', 'type': , 'version': 1, diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py index 7ecf1657ce9..e3e3c97034c 100644 --- a/tests/components/twentemilieu/conftest.py +++ b/tests/components/twentemilieu/conftest.py @@ -51,7 +51,8 @@ def mock_twentemilieu() -> Generator[MagicMock]: """Return a mocked Twente Milieu client.""" with ( patch( - "homeassistant.components.twentemilieu.TwenteMilieu", autospec=True + "homeassistant.components.twentemilieu.coordinator.TwenteMilieu", + autospec=True, ) as twentemilieu_mock, patch( "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr deleted file mode 100644 index a98119e81c9..00000000000 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,93 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': '12345', - }), - 'data': dict({ - 'house_letter': 'A', - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'twentemilieu', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'house_letter': 'A', - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'twentemilieu', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': '12345', - 'unique_id': '12345', - 'version': 1, - }), - 'title': '12345', - 'type': , - 'version': 1, - }) -# --- -# name: test_invalid_address - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': '12345', - }), - 'data': dict({ - 'house_letter': None, - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'twentemilieu', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'house_letter': None, - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'twentemilieu', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': '12345', - 'unique_id': '12345', - 'version': 1, - }), - 'title': '12345', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index dbc01c69acb..6dc261b8769 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock import pytest -from syrupy.assertion import SnapshotAssertion from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError from homeassistant import config_entries @@ -15,6 +14,7 @@ from homeassistant.components.twentemilieu.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -24,16 +24,16 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("mock_twentemilieu") -async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +async def test_full_user_flow(hass: HomeAssistant) -> None: """Test registering an integration and finishing flow works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_POST_CODE: "1234AB", @@ -42,14 +42,22 @@ async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "12345" + assert config_entry.data == { + CONF_HOUSE_LETTER: "A", + CONF_HOUSE_NUMBER: "1", + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + } + assert not config_entry.options async def test_invalid_address( hass: HomeAssistant, mock_twentemilieu: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test full user flow when the user enters an incorrect address. @@ -60,11 +68,11 @@ async def test_invalid_address( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" mock_twentemilieu.unique_id.side_effect = TwenteMilieuAddressError - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_POST_CODE: "1234", @@ -72,12 +80,12 @@ async def test_invalid_address( }, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "user" - assert result2.get("errors") == {"base": "invalid_address"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_address"} mock_twentemilieu.unique_id.side_effect = None - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_POST_CODE: "1234AB", @@ -85,8 +93,17 @@ async def test_invalid_address( }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "12345" + assert config_entry.data == { + CONF_HOUSE_LETTER: None, + CONF_HOUSE_NUMBER: "1", + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + } + assert not config_entry.options async def test_connection_error( @@ -106,9 +123,33 @@ async def test_connection_error( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Recover from error + mock_twentemilieu.unique_id.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "12345" + assert config_entry.data == { + CONF_HOUSE_LETTER: "A", + CONF_HOUSE_NUMBER: "1", + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + } + assert not config_entry.options @pytest.mark.usefixtures("mock_twentemilieu") @@ -128,5 +169,5 @@ async def test_address_already_set_up( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index 7e08b5f4938..5cc09e6875d 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -29,7 +29,7 @@ async def test_load_unload_config_entry( @patch( - "homeassistant.components.twentemilieu.TwenteMilieu.update", + "homeassistant.components.twentemilieu.coordinator.TwenteMilieu.update", side_effect=RuntimeError, ) async def test_config_entry_not_ready( diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 28ec98cf572..e52f76634fd 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Twinkly', 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'version': 1, diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index 2d70aaf9649..1887861f6e5 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -5,7 +5,7 @@ from typing import Any, Generic, TypeVar from twitchAPI.object.base import TwitchObject -from homeassistant.components.twitch import DOMAIN +from homeassistant.components.twitch.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_json_array_fixture diff --git a/tests/components/twitch/test_sensor.py b/tests/components/twitch/test_sensor.py index 613c0919c49..c8cc009f3e1 100644 --- a/tests/components/twitch/test_sensor.py +++ b/tests/components/twitch/test_sensor.py @@ -7,7 +7,7 @@ from dateutil.tz import tzutc from twitchAPI.object.api import FollowedChannel, Stream, UserSubscription from twitchAPI.type import TwitchResourceNotFound -from homeassistant.components.twitch import DOMAIN +from homeassistant.components.twitch.const import DOMAIN from homeassistant.core import HomeAssistant from . import TwitchIterObject, get_generator_from_data, setup_integration diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index 4ba90a00113..aa7337be0ba 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -42,6 +42,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '1', 'version': 1, diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index fad65c095df..3ed559b71ec 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -51,11 +51,11 @@ def mock_nvr(): nvr = NVR.from_unifi_dict(**data) # disable pydantic validation so mocking can happen - NVR.__config__.validate_assignment = False + NVR.model_config["validate_assignment"] = False yield nvr - NVR.__config__.validate_assignment = True + NVR.model_config["validate_assignment"] = True @pytest.fixture(name="ufp_config_entry") @@ -120,7 +120,11 @@ def mock_ufp_client(bootstrap: Bootstrap): client.base_url = "https://127.0.0.1" client.connection_host = IPv4Address("127.0.0.1") - client.get_nvr = AsyncMock(return_value=nvr) + + async def get_nvr(*args: Any, **kwargs: Any) -> NVR: + return client.bootstrap.nvr + + client.get_nvr = get_nvr client.get_bootstrap = AsyncMock(return_value=bootstrap) client.update = AsyncMock(return_value=bootstrap) client.async_disconnect_ws = AsyncMock() @@ -173,7 +177,7 @@ def camera_fixture(fixed_now: datetime): """Mock UniFi Protect Camera device.""" # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False + Camera.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_camera.json", integration=DOMAIN)) camera = Camera.from_unifi_dict(**data) @@ -181,23 +185,23 @@ def camera_fixture(fixed_now: datetime): yield camera - Camera.__config__.validate_assignment = True + Camera.model_config["validate_assignment"] = True @pytest.fixture(name="camera_all") def camera_all_fixture(camera: Camera): """Mock UniFi Protect Camera device.""" - all_camera = camera.copy() - all_camera.channels = [all_camera.channels[0].copy()] + all_camera = camera.model_copy() + all_camera.channels = [all_camera.channels[0].model_copy()] - medium_channel = all_camera.channels[0].copy() + medium_channel = all_camera.channels[0].model_copy() medium_channel.name = "Medium" medium_channel.id = 1 medium_channel.rtsp_alias = "test_medium_alias" all_camera.channels.append(medium_channel) - low_channel = all_camera.channels[0].copy() + low_channel = all_camera.channels[0].model_copy() low_channel.name = "Low" low_channel.id = 2 low_channel.rtsp_alias = "test_medium_alias" @@ -210,10 +214,10 @@ def camera_all_fixture(camera: Camera): def doorbell_fixture(camera: Camera, fixed_now: datetime): """Mock UniFi Protect Camera device (with chime).""" - doorbell = camera.copy() - doorbell.channels = [c.copy() for c in doorbell.channels] + doorbell = camera.model_copy() + doorbell.channels = [c.model_copy() for c in doorbell.channels] - package_channel = doorbell.channels[0].copy() + package_channel = doorbell.channels[0].model_copy() package_channel.name = "Package Camera" package_channel.id = 3 package_channel.fps = 2 @@ -247,8 +251,8 @@ def doorbell_fixture(camera: Camera, fixed_now: datetime): def unadopted_camera(camera: Camera): """Mock UniFi Protect Camera device (unadopted).""" - no_camera = camera.copy() - no_camera.channels = [c.copy() for c in no_camera.channels] + no_camera = camera.model_copy() + no_camera.channels = [c.model_copy() for c in no_camera.channels] no_camera.name = "Unadopted Camera" no_camera.is_adopted = False return no_camera @@ -259,19 +263,19 @@ def light_fixture(): """Mock UniFi Protect Light device.""" # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False + Light.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) yield Light.from_unifi_dict(**data) - Light.__config__.validate_assignment = True + Light.model_config["validate_assignment"] = True @pytest.fixture def unadopted_light(light: Light): """Mock UniFi Protect Light device (unadopted).""" - no_light = light.copy() + no_light = light.model_copy() no_light.name = "Unadopted Light" no_light.is_adopted = False return no_light @@ -282,12 +286,12 @@ def viewer(): """Mock UniFi Protect Viewport device.""" # disable pydantic validation so mocking can happen - Viewer.__config__.validate_assignment = False + Viewer.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_viewport.json", integration=DOMAIN)) yield Viewer.from_unifi_dict(**data) - Viewer.__config__.validate_assignment = True + Viewer.model_config["validate_assignment"] = True @pytest.fixture(name="sensor") @@ -295,7 +299,7 @@ def sensor_fixture(fixed_now: datetime): """Mock UniFi Protect Sensor device.""" # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False + Sensor.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_sensor.json", integration=DOMAIN)) sensor: Sensor = Sensor.from_unifi_dict(**data) @@ -304,14 +308,14 @@ def sensor_fixture(fixed_now: datetime): sensor.alarm_triggered_at = fixed_now - timedelta(hours=1) yield sensor - Sensor.__config__.validate_assignment = True + Sensor.model_config["validate_assignment"] = True @pytest.fixture(name="sensor_all") def csensor_all_fixture(sensor: Sensor): """Mock UniFi Protect Sensor device.""" - all_sensor = sensor.copy() + all_sensor = sensor.model_copy() all_sensor.light_settings.is_enabled = True all_sensor.humidity_settings.is_enabled = True all_sensor.temperature_settings.is_enabled = True @@ -327,19 +331,19 @@ def doorlock_fixture(): """Mock UniFi Protect Doorlock device.""" # disable pydantic validation so mocking can happen - Doorlock.__config__.validate_assignment = False + Doorlock.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) yield Doorlock.from_unifi_dict(**data) - Doorlock.__config__.validate_assignment = True + Doorlock.model_config["validate_assignment"] = True @pytest.fixture def unadopted_doorlock(doorlock: Doorlock): """Mock UniFi Protect Light device (unadopted).""" - no_doorlock = doorlock.copy() + no_doorlock = doorlock.model_copy() no_doorlock.name = "Unadopted Lock" no_doorlock.is_adopted = False return no_doorlock @@ -350,12 +354,12 @@ def chime(): """Mock UniFi Protect Chime device.""" # disable pydantic validation so mocking can happen - Chime.__config__.validate_assignment = False + Chime.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_chime.json", integration=DOMAIN)) yield Chime.from_unifi_dict(**data) - Chime.__config__.validate_assignment = True + Chime.model_config["validate_assignment"] = True @pytest.fixture(name="fixed_now") diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 31669aa62bb..3a8d5d952ce 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -305,7 +305,7 @@ async def test_binary_sensor_update_motion( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id @@ -352,7 +352,7 @@ async def test_binary_sensor_update_light_motion( api=ufp.api, ) - new_light = light.copy() + new_light = light.model_copy() new_light.is_pir_motion_detected = True new_light.last_motion_event_id = event.id @@ -386,7 +386,7 @@ async def test_binary_sensor_update_mount_type_window( assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_sensor = sensor_all.copy() + new_sensor = sensor_all.model_copy() new_sensor.mount_type = MountType.WINDOW mock_msg = Mock() @@ -418,7 +418,7 @@ async def test_binary_sensor_update_mount_type_garage( assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_sensor = sensor_all.copy() + new_sensor = sensor_all.model_copy() new_sensor.mount_type = MountType.GARAGE mock_msg = Mock() @@ -468,7 +468,7 @@ async def test_binary_sensor_package_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id @@ -501,7 +501,7 @@ async def test_binary_sensor_package_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id @@ -534,7 +534,7 @@ async def test_binary_sensor_package_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id @@ -611,7 +611,7 @@ async def test_binary_sensor_person_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True ufp.api.bootstrap.cameras = {new_camera.id: new_camera} @@ -641,7 +641,7 @@ async def test_binary_sensor_person_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id @@ -680,7 +680,7 @@ async def test_binary_sensor_person_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 689352d8aa3..12b92beedd0 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -236,15 +236,15 @@ async def test_basic_setup( ) -> None: """Test working setup of unifiprotect entry.""" - camera_high_only = camera_all.copy() - camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only = camera_all.model_copy() + camera_high_only.channels = [c.model_copy() for c in camera_all.channels] camera_high_only.name = "Test Camera 1" camera_high_only.channels[0].is_rtsp_enabled = True camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[2].is_rtsp_enabled = False - camera_medium_only = camera_all.copy() - camera_medium_only.channels = [c.copy() for c in camera_all.channels] + camera_medium_only = camera_all.model_copy() + camera_medium_only.channels = [c.model_copy() for c in camera_all.channels] camera_medium_only.name = "Test Camera 2" camera_medium_only.channels[0].is_rtsp_enabled = False camera_medium_only.channels[1].is_rtsp_enabled = True @@ -252,8 +252,8 @@ async def test_basic_setup( camera_all.name = "Test Camera 3" - camera_no_channels = camera_all.copy() - camera_no_channels.channels = [c.copy() for c in camera_all.channels] + camera_no_channels = camera_all.model_copy() + camera_no_channels.channels = [c.model_copy() for c in camera_all.channels] camera_no_channels.name = "Test Camera 4" camera_no_channels.channels[0].is_rtsp_enabled = False camera_no_channels.channels[1].is_rtsp_enabled = False @@ -337,8 +337,8 @@ async def test_webrtc_support( camera_all: ProtectCamera, ) -> None: """Test webrtc support is available.""" - camera_high_only = camera_all.copy() - camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only = camera_all.model_copy() + camera_high_only.channels = [c.model_copy() for c in camera_all.channels] camera_high_only.name = "Test Camera 1" camera_high_only.channels[0].is_rtsp_enabled = True camera_high_only.channels[1].is_rtsp_enabled = False @@ -355,7 +355,7 @@ async def test_adopt( ) -> None: """Test setting up camera with no camera channels.""" - camera1 = camera.copy() + camera1 = camera.model_copy() camera1.channels = [] await init_entry(hass, ufp, [camera1]) @@ -450,7 +450,7 @@ async def test_camera_interval_update( state = hass.states.get(entity_id) assert state and state.state == "idle" - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_recording = True ufp.api.bootstrap.cameras = {new_camera.id: new_camera} @@ -527,10 +527,10 @@ async def test_camera_ws_update( state = hass.states.get(entity_id) assert state and state.state == "idle" - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_recording = True - no_camera = camera.copy() + no_camera = camera.model_copy() no_camera.is_adopted = False ufp.api.bootstrap.cameras = {new_camera.id: new_camera} @@ -563,7 +563,7 @@ async def test_camera_ws_update_offline( assert state and state.state == "idle" # camera goes offline - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.state = StateType.DISCONNECTED mock_msg = Mock() @@ -601,7 +601,7 @@ async def test_camera_enable_motion( assert_entity_counts(hass, Platform.CAMERA, 2, 1) entity_id = "camera.test_camera_high_resolution_channel" - camera.__fields__["set_motion_detection"] = Mock(final=False) + camera.__pydantic_fields__["set_motion_detection"] = Mock(final=False, frozen=False) camera.set_motion_detection = AsyncMock() await hass.services.async_call( @@ -623,7 +623,7 @@ async def test_camera_disable_motion( assert_entity_counts(hass, Platform.CAMERA, 2, 1) entity_id = "camera.test_camera_high_resolution_channel" - camera.__fields__["set_motion_detection"] = Mock(final=False) + camera.__pydantic_fields__["set_motion_detection"] = Mock(final=False, frozen=False) camera.set_motion_detection = AsyncMock() await hass.services.async_call( diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index cc2195c1dba..6a26738f5e8 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -75,7 +75,7 @@ async def test_doorbell_ring( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.last_ring_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -107,7 +107,7 @@ async def test_doorbell_ring( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -137,7 +137,7 @@ async def test_doorbell_ring( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -190,7 +190,7 @@ async def test_doorbell_nfc_scanned( metadata={"nfc": {"nfc_id": "test_nfc_id", "user_id": "test_user_id"}}, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.last_nfc_card_scanned_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -248,7 +248,7 @@ async def test_doorbell_fingerprint_identified( metadata={"fingerprint": {"ulp_id": "test_ulp_id"}}, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.last_fingerprint_identified_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -306,7 +306,7 @@ async def test_doorbell_fingerprint_not_identified( metadata={"fingerprint": {}}, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.last_fingerprint_identified_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 0d88754a110..b01c7e0cf4a 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -118,7 +118,7 @@ async def test_setup_too_old( ) -> None: """Test setup of unifiprotect entry with too old of version of UniFi Protect.""" - old_bootstrap = ufp.api.bootstrap.copy() + old_bootstrap = ufp.api.bootstrap.model_copy() old_bootstrap.nvr = old_nvr ufp.api.update.return_value = old_bootstrap ufp.api.bootstrap = old_bootstrap diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index bb0b6992e4e..724ed108673 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -74,7 +74,7 @@ async def test_light_update( await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - new_light = light.copy() + new_light = light.model_copy() new_light.is_light_on = True new_light.light_device_settings.led_level = LEDLevel(3) @@ -101,7 +101,7 @@ async def test_light_turn_on( assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__fields__["set_light"] = Mock(final=False) + light.__pydantic_fields__["set_light"] = Mock(final=False, frozen=False) light.set_light = AsyncMock() await hass.services.async_call( @@ -123,7 +123,7 @@ async def test_light_turn_off( assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__fields__["set_light"] = Mock(final=False) + light.__pydantic_fields__["set_light"] = Mock(final=False, frozen=False) light.set_light = AsyncMock() await hass.services.async_call( diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 8b37b1c5928..9095c092ea2 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -75,7 +75,7 @@ async def test_lock_locked( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() @@ -102,7 +102,7 @@ async def test_lock_unlocking( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.OPENING mock_msg = Mock() @@ -129,7 +129,7 @@ async def test_lock_locking( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.CLOSING mock_msg = Mock() @@ -156,7 +156,7 @@ async def test_lock_jammed( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.JAMMED_WHILE_CLOSING mock_msg = Mock() @@ -183,7 +183,7 @@ async def test_lock_unavailable( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.NOT_CALIBRATED mock_msg = Mock() @@ -210,7 +210,7 @@ async def test_lock_do_lock( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - doorlock.__fields__["close_lock"] = Mock(final=False) + doorlock.__pydantic_fields__["close_lock"] = Mock(final=False, frozen=False) doorlock.close_lock = AsyncMock() await hass.services.async_call( @@ -234,7 +234,7 @@ async def test_lock_do_unlock( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() @@ -245,7 +245,7 @@ async def test_lock_do_unlock( ufp.ws_msg(mock_msg) await hass.async_block_till_done() - new_lock.__fields__["open_lock"] = Mock(final=False) + doorlock.__pydantic_fields__["open_lock"] = Mock(final=False, frozen=False) new_lock.open_lock = AsyncMock() await hass.services.async_call( diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 642a3a1e372..6d27eb2a206 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -88,7 +88,7 @@ async def test_media_player_update( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.talkback_stream = Mock() new_camera.talkback_stream.is_running = True @@ -116,7 +116,7 @@ async def test_media_player_set_volume( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["set_speaker_volume"] = Mock(final=False) + doorbell.__pydantic_fields__["set_speaker_volume"] = Mock(final=False, frozen=False) doorbell.set_speaker_volume = AsyncMock() await hass.services.async_call( @@ -140,7 +140,7 @@ async def test_media_player_stop( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.talkback_stream = AsyncMock() new_camera.talkback_stream.is_running = True @@ -173,9 +173,11 @@ async def test_media_player_play( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["stop_audio"] = Mock(final=False) - doorbell.__fields__["play_audio"] = Mock(final=False) - doorbell.__fields__["wait_until_audio_completes"] = Mock(final=False) + doorbell.__pydantic_fields__["stop_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["wait_until_audio_completes"] = Mock( + final=False, frozen=False + ) doorbell.stop_audio = AsyncMock() doorbell.play_audio = AsyncMock() doorbell.wait_until_audio_completes = AsyncMock() @@ -208,9 +210,11 @@ async def test_media_player_play_media_source( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["stop_audio"] = Mock(final=False) - doorbell.__fields__["play_audio"] = Mock(final=False) - doorbell.__fields__["wait_until_audio_completes"] = Mock(final=False) + doorbell.__pydantic_fields__["stop_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["wait_until_audio_completes"] = Mock( + final=False, frozen=False + ) doorbell.stop_audio = AsyncMock() doorbell.play_audio = AsyncMock() doorbell.wait_until_audio_completes = AsyncMock() @@ -247,7 +251,7 @@ async def test_media_player_play_invalid( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["play_audio"] = Mock(final=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) doorbell.play_audio = AsyncMock() with pytest.raises(HomeAssistantError): @@ -276,8 +280,10 @@ async def test_media_player_play_error( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["play_audio"] = Mock(final=False) - doorbell.__fields__["wait_until_audio_completes"] = Mock(final=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["wait_until_audio_completes"] = Mock( + final=False, frozen=False + ) doorbell.play_audio = AsyncMock(side_effect=StreamError) doorbell.wait_until_audio_completes = AsyncMock() diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 18944460ca5..61f9680bdbc 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -204,9 +204,9 @@ async def test_browse_media_root_multiple_consoles( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - bootstrap2 = bootstrap.copy() + bootstrap2 = bootstrap.model_copy() bootstrap2._has_media = True - bootstrap2.nvr = bootstrap.nvr.copy() + bootstrap2.nvr = bootstrap.nvr.model_copy() bootstrap2.nvr.id = "test_id2" bootstrap2.nvr.mac = "A2E00C826924" bootstrap2.nvr.name = "UnifiProtect2" @@ -270,9 +270,9 @@ async def test_browse_media_root_multiple_consoles_only_one_media( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - bootstrap2 = bootstrap.copy() + bootstrap2 = bootstrap.model_copy() bootstrap2._has_media = False - bootstrap2.nvr = bootstrap.nvr.copy() + bootstrap2.nvr = bootstrap.nvr.model_copy() bootstrap2.nvr.id = "test_id2" bootstrap2.nvr.mac = "A2E00C826924" bootstrap2.nvr.name = "UnifiProtect2" diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 77a409551b1..1838a574bc4 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -162,7 +162,7 @@ async def test_number_light_sensitivity( description = LIGHT_NUMBERS[0] assert description.ufp_set_method is not None - light.__fields__["set_sensitivity"] = Mock(final=False) + light.__pydantic_fields__["set_sensitivity"] = Mock(final=False, frozen=False) light.set_sensitivity = AsyncMock() _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) @@ -184,7 +184,7 @@ async def test_number_light_duration( description = LIGHT_NUMBERS[1] - light.__fields__["set_duration"] = Mock(final=False) + light.__pydantic_fields__["set_duration"] = Mock(final=False, frozen=False) light.set_duration = AsyncMock() _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) @@ -210,7 +210,9 @@ async def test_number_camera_simple( assert description.ufp_set_method is not None - camera.__fields__[description.ufp_set_method] = Mock(final=False) + camera.__pydantic_fields__[description.ufp_set_method] = Mock( + final=False, frozen=False + ) setattr(camera, description.ufp_set_method, AsyncMock()) _, entity_id = ids_from_device_description(Platform.NUMBER, camera, description) @@ -230,7 +232,9 @@ async def test_number_lock_auto_close( description = DOORLOCK_NUMBERS[0] - doorlock.__fields__["set_auto_close_time"] = Mock(final=False) + doorlock.__pydantic_fields__["set_auto_close_time"] = Mock( + final=False, frozen=False + ) doorlock.set_auto_close_time = AsyncMock() _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index fe102c2fdbc..1f025a63306 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -51,7 +51,7 @@ async def test_exclude_attributes( camera_id=doorbell.id, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index adb9555e6ea..1117038bbd0 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -363,3 +363,30 @@ async def test_rtsp_writable_fix_when_not_setup( ufp.api.update_device.assert_called_with( ModelType.CAMERA, doorbell.id, {"channels": channels} ) + + +async def test_rtsp_no_fix_if_third_party( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test no RTSP disabled warning if camera is third-party.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + for user in ufp.api.bootstrap.users.values(): + user.all_permissions = [] + + ufp.api.get_camera = AsyncMock(return_value=doorbell) + doorbell.is_third_party_camera = True + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert not msg["result"]["issues"] diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 8795af57214..6db3ae22dcb 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -262,7 +262,7 @@ async def test_select_update_doorbell_settings( expected_length += 1 new_nvr = copy(ufp.api.bootstrap.nvr) - new_nvr.__fields__["update_all_messages"] = Mock(final=False) + new_nvr.__pydantic_fields__["update_all_messages"] = Mock(final=False, frozen=False) new_nvr.update_all_messages = Mock() new_nvr.doorbell_settings.all_messages = [ @@ -304,7 +304,7 @@ async def test_select_update_doorbell_message( assert state assert state.state == "Default Message (Welcome)" - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.lcd_message = LCDMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test" ) @@ -332,7 +332,7 @@ async def test_select_set_option_light_motion( _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) - light.__fields__["set_light_settings"] = Mock(final=False) + light.__pydantic_fields__["set_light_settings"] = Mock(final=False, frozen=False) light.set_light_settings = AsyncMock() await hass.services.async_call( @@ -357,7 +357,7 @@ async def test_select_set_option_light_camera( _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) - light.__fields__["set_paired_camera"] = Mock(final=False) + light.__pydantic_fields__["set_paired_camera"] = Mock(final=False, frozen=False) light.set_paired_camera = AsyncMock() camera = list(light.api.bootstrap.cameras.values())[0] @@ -393,7 +393,7 @@ async def test_select_set_option_camera_recording( Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) - doorbell.__fields__["set_recording_mode"] = Mock(final=False) + doorbell.__pydantic_fields__["set_recording_mode"] = Mock(final=False, frozen=False) doorbell.set_recording_mode = AsyncMock() await hass.services.async_call( @@ -418,7 +418,7 @@ async def test_select_set_option_camera_ir( Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) - doorbell.__fields__["set_ir_led_model"] = Mock(final=False) + doorbell.__pydantic_fields__["set_ir_led_model"] = Mock(final=False, frozen=False) doorbell.set_ir_led_model = AsyncMock() await hass.services.async_call( @@ -443,7 +443,7 @@ async def test_select_set_option_camera_doorbell_custom( Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( @@ -470,7 +470,7 @@ async def test_select_set_option_camera_doorbell_unifi( Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( @@ -512,7 +512,7 @@ async def test_select_set_option_camera_doorbell_default( Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( @@ -541,7 +541,7 @@ async def test_select_set_option_viewer( Platform.SELECT, viewer, VIEWER_SELECTS[0] ) - viewer.__fields__["set_liveview"] = Mock(final=False) + viewer.__pydantic_fields__["set_liveview"] = Mock(final=False, frozen=False) viewer.set_liveview = AsyncMock() liveview = list(viewer.api.bootstrap.liveviews.values())[0] diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index bc5f372c598..9489a49bf22 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -464,7 +464,7 @@ async def test_sensor_update_alarm( api=ufp.api, ) - new_sensor = sensor_all.copy() + new_sensor = sensor_all.model_copy() new_sensor.set_alarm_timeout() new_sensor.last_alarm_event_id = event.id @@ -548,7 +548,7 @@ async def test_camera_update_license_plate( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id @@ -663,7 +663,7 @@ async def test_camera_update_license_plate_changes_number_during_detect( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id @@ -750,7 +750,7 @@ async def test_camera_update_license_plate_multiple_updates( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id @@ -873,7 +873,7 @@ async def test_camera_update_license_no_dupes( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 6808bacb40c..84e0e74a492 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -56,7 +56,9 @@ async def test_global_service_bad_device( """Test global service, invalid device ID.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock() with pytest.raises(HomeAssistantError): @@ -75,7 +77,9 @@ async def test_global_service_exception( """Test global service, unexpected error.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock(side_effect=BadRequest) with pytest.raises(HomeAssistantError): @@ -94,7 +98,9 @@ async def test_add_doorbell_text( """Test add_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock() await hass.services.async_call( @@ -112,7 +118,9 @@ async def test_remove_doorbell_text( """Test remove_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["remove_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["remove_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.remove_custom_doorbell_message = AsyncMock() await hass.services.async_call( @@ -129,7 +137,9 @@ async def test_add_doorbell_text_disabled_config_entry( ) -> None: """Test add_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock() await hass.config_entries.async_set_disabled_by( @@ -158,10 +168,10 @@ async def test_set_chime_paired_doorbells( ufp.api.update_device = AsyncMock() - camera1 = doorbell.copy() + camera1 = doorbell.model_copy() camera1.name = "Test Camera 1" - camera2 = doorbell.copy() + camera2 = doorbell.model_copy() camera2.name = "Test Camera 2" await init_entry(hass, ufp, [camera1, camera2, chime]) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 9e0e9efa0ce..194e46681ce 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -89,7 +89,7 @@ async def test_switch_nvr(hass: HomeAssistant, ufp: MockUFPFixture) -> None: assert_entity_counts(hass, Platform.SWITCH, 2, 2) nvr = ufp.api.bootstrap.nvr - nvr.__fields__["set_insights"] = Mock(final=False) + nvr.__pydantic_fields__["set_insights"] = Mock(final=False, frozen=False) nvr.set_insights = AsyncMock() entity_id = "switch.unifiprotect_insights_enabled" @@ -272,7 +272,7 @@ async def test_switch_light_status( description = LIGHT_SWITCHES[1] - light.__fields__["set_status_light"] = Mock(final=False) + light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) light.set_status_light = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) @@ -300,7 +300,7 @@ async def test_switch_camera_ssh( description = CAMERA_SWITCHES[0] - doorbell.__fields__["set_ssh"] = Mock(final=False) + doorbell.__pydantic_fields__["set_ssh"] = Mock(final=False, frozen=False) doorbell.set_ssh = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) @@ -333,7 +333,9 @@ async def test_switch_camera_simple( assert description.ufp_set_method is not None - doorbell.__fields__[description.ufp_set_method] = Mock(final=False) + doorbell.__pydantic_fields__[description.ufp_set_method] = Mock( + final=False, frozen=False + ) setattr(doorbell, description.ufp_set_method, AsyncMock()) set_method = getattr(doorbell, description.ufp_set_method) @@ -362,7 +364,7 @@ async def test_switch_camera_highfps( description = CAMERA_SWITCHES[3] - doorbell.__fields__["set_video_mode"] = Mock(final=False) + doorbell.__pydantic_fields__["set_video_mode"] = Mock(final=False, frozen=False) doorbell.set_video_mode = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) @@ -393,7 +395,7 @@ async def test_switch_camera_privacy( description = PRIVACY_MODE_SWITCH - doorbell.__fields__["set_privacy"] = Mock(final=False) + doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) @@ -409,7 +411,7 @@ async def test_switch_camera_privacy( doorbell.set_privacy.assert_called_with(True, 0, RecordingMode.NEVER) - new_doorbell = doorbell.copy() + new_doorbell = doorbell.model_copy() new_doorbell.add_privacy_zone() new_doorbell.mic_volume = 0 new_doorbell.recording_settings.mode = RecordingMode.NEVER @@ -445,7 +447,7 @@ async def test_switch_camera_privacy_already_on( description = PRIVACY_MODE_SWITCH - doorbell.__fields__["set_privacy"] = Mock(final=False) + doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index 3ca11744abb..c34611c43a9 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -78,7 +78,7 @@ async def test_text_camera_set( Platform.TEXT, doorbell, description ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index a35f7bb0f12..d4916de8039 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -896,98 +896,6 @@ async def test_name(hass: HomeAssistant) -> None: assert expected.items() <= state.attributes.items() -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockUpdateEntity(UpdateEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockUpdateEntity() - assert entity.supported_features_compat is UpdateEntityFeature(1) - assert "MockUpdateEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "UpdateEntityFeature.INSTALL" in caplog.text - caplog.clear() - assert entity.supported_features_compat is UpdateEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - -async def test_deprecated_supported_features_ints_with_service_call( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test deprecated supported features ints with install service.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - - class MockUpdateEntity(UpdateEntity): - _attr_supported_features = 1 | 2 - - def install(self, version: str | None = None, backup: bool = False) -> None: - """Install an update.""" - - entity = MockUpdateEntity() - entity.entity_id = ( - "update.test_deprecated_supported_features_ints_with_service_call" - ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test update platform via config entry.""" - async_add_entities([entity]) - - mock_platform( - hass, - f"{TEST_DOMAIN}.{DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), - ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert "is using deprecated supported features values" in caplog.text - - assert isinstance(entity.supported_features, int) - - with pytest.raises( - HomeAssistantError, - match="Backup is not supported for update.test_deprecated_supported_features_ints_with_service_call", - ): - await hass.services.async_call( - DOMAIN, - SERVICE_INSTALL, - { - ATTR_VERSION: "0.9.9", - ATTR_BACKUP: True, - ATTR_ENTITY_ID: "update.test_deprecated_supported_features_ints_with_service_call", - }, - blocking=True, - ) - - async def test_custom_version_is_newer(hass: HomeAssistant) -> None: """Test UpdateEntity with overridden version_is_newer method.""" @@ -1032,7 +940,7 @@ async def test_custom_version_is_newer(hass: HomeAssistant) -> None: ("supported_features", "extra_expected_attributes"), [ ( - 0, + UpdateEntityFeature(0), [ {}, {}, diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index 38312667375..93b1da60998 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -27,10 +27,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Uptime', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Uptime', 'type': , 'version': 1, diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index 6cdf121d7e3..ef235bba99d 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Energy Bill', 'unique_id': None, 'version': 2, diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 96567b80c54..780a00acd64 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'ABC123', 'version': 1, diff --git a/tests/components/vacuum/__init__.py b/tests/components/vacuum/__init__.py index 0a681730cb2..26e31a87eee 100644 --- a/tests/components/vacuum/__init__.py +++ b/tests/components/vacuum/__init__.py @@ -4,12 +4,8 @@ from typing import Any from homeassistant.components.vacuum import ( DOMAIN, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -39,20 +35,20 @@ class MockVacuum(MockEntity, StateVacuumEntity): def __init__(self, **values: Any) -> None: """Initialize a mock vacuum entity.""" super().__init__(**values) - self._attr_state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._attr_fan_speed = "slow" def stop(self, **kwargs: Any) -> None: """Stop cleaning.""" - self._attr_state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE def return_to_base(self, **kwargs: Any) -> None: """Return to base.""" - self._attr_state = STATE_RETURNING + self._attr_activity = VacuumActivity.RETURNING def clean_spot(self, **kwargs: Any) -> None: """Clean a spot.""" - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set the fan speed.""" @@ -60,11 +56,11 @@ class MockVacuum(MockEntity, StateVacuumEntity): def start(self) -> None: """Start cleaning.""" - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING def pause(self) -> None: """Pause cleaning.""" - self._attr_state = STATE_PAUSED + self._attr_activity = VacuumActivity.PAUSED async def help_async_setup_entry_init( diff --git a/tests/components/vacuum/conftest.py b/tests/components/vacuum/conftest.py index d298260c575..6e6639431d0 100644 --- a/tests/components/vacuum/conftest.py +++ b/tests/components/vacuum/conftest.py @@ -1,13 +1,28 @@ """Fixtures for Vacuum platform tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch import pytest -from homeassistant.config_entries import ConfigFlow +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntityFeature +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, frame +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import mock_config_flow, mock_platform +from . import MockVacuum + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" class MockFlow(ConfigFlow): @@ -17,7 +32,94 @@ class MockFlow(ConfigFlow): @pytest.fixture def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" - mock_platform(hass, "test.config_flow") + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with mock_config_flow("test", MockFlow): + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(name="supported_features") +async def vacuum_supported_features() -> VacuumEntityFeature: + """Return the supported features for the test vacuum entity.""" + return ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.MAP + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + + +@pytest.fixture(name="mock_vacuum_entity") +async def setup_vacuum_platform_test_entity( + hass: HomeAssistant, + config_flow_fixture: None, + entity_registry: er.EntityRegistry, + supported_features: VacuumEntityFeature, +) -> MagicMock: + """Set up vacuum entity using an entity platform.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [VACUUM_DOMAIN] + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + entity = MockVacuum( + supported_features=supported_features, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test vacuum platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{VACUUM_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + + return entity + + +@pytest.fixture(name="mock_as_custom_component") +async def mock_frame(hass: HomeAssistant) -> AsyncGenerator[None]: + """Mock frame.""" + with patch( + "homeassistant.helpers.frame.get_integration_frame", + return_value=frame.IntegrationFrame( + custom_integration=True, + integration="alarm_control_panel", + module="test_init.py", + relative_filename="test_init.py", + frame=frame.get_current_frame(), + ), + ): yield diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 9a2a67f7141..5a1b1fea7de 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -5,12 +5,7 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.vacuum import ( - DOMAIN, - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, -) +from homeassistant.components.vacuum import DOMAIN, VacuumActivity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -122,7 +117,7 @@ async def test_if_state( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -174,7 +169,7 @@ async def test_if_state( assert len(service_calls) == 1 assert service_calls[0].data["some"] == "is_docked - event - test_event2" - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -182,7 +177,7 @@ async def test_if_state( assert service_calls[1].data["some"] == "is_cleaning - event - test_event1" # Returning means it's still cleaning - hass.states.async_set(entry.entity_id, STATE_RETURNING) + hass.states.async_set(entry.entity_id, VacuumActivity.RETURNING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -207,7 +202,7 @@ async def test_if_state_legacy( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) assert await async_setup_component( hass, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index c186bd4d9eb..3a0cbafb4a1 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -7,7 +7,7 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.vacuum import DOMAIN, STATE_CLEANING, STATE_DOCKED +from homeassistant.components.vacuum import DOMAIN, VacuumActivity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -188,7 +188,7 @@ async def test_if_fires_on_state_change( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -238,7 +238,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is cleaning - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) await hass.async_block_till_done() assert len(service_calls) == 1 assert ( @@ -247,7 +247,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is docked - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) await hass.async_block_till_done() assert len(service_calls) == 2 assert ( @@ -273,7 +273,7 @@ async def test_if_fires_on_state_change_legacy( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -304,7 +304,7 @@ async def test_if_fires_on_state_change_legacy( ) # Fake that the entity is cleaning - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) await hass.async_block_till_done() assert len(service_calls) == 1 assert ( @@ -330,7 +330,7 @@ async def test_if_fires_on_state_change_with_for( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -365,7 +365,7 @@ async def test_if_fires_on_state_change_with_for( await hass.async_block_till_done() assert len(service_calls) == 0 - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) await hass.async_block_till_done() assert len(service_calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index d03f1d28b58..db6cd242f3f 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,12 +5,13 @@ from __future__ import annotations from enum import Enum from types import ModuleType from typing import Any +from unittest.mock import patch import pytest from homeassistant.components import vacuum from homeassistant.components.vacuum import ( - DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -19,19 +20,19 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry +from .common import async_start from tests.common import ( MockConfigEntry, + MockEntity, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -72,14 +73,33 @@ def test_deprecated_constants( ) +@pytest.mark.parametrize( + ("enum", "constant_prefix"), _create_tuples(vacuum.VacuumActivity, "STATE_") +) +@pytest.mark.parametrize( + "module", + [vacuum], +) +def test_deprecated_constants_for_state( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2026.1" + ) + + @pytest.mark.parametrize( ("service", "expected_state"), [ - (SERVICE_CLEAN_SPOT, STATE_CLEANING), - (SERVICE_PAUSE, STATE_PAUSED), - (SERVICE_RETURN_TO_BASE, STATE_RETURNING), - (SERVICE_START, STATE_CLEANING), - (SERVICE_STOP, STATE_IDLE), + (SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + (SERVICE_PAUSE, VacuumActivity.PAUSED), + (SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (SERVICE_START, VacuumActivity.CLEANING), + (SERVICE_STOP, VacuumActivity.IDLE), ], ) async def test_state_services( @@ -101,18 +121,20 @@ async def test_state_services( async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, service, {"entity_id": mock_vacuum.entity_id}, blocking=True, ) - vacuum_state = hass.states.get(mock_vacuum.entity_id) + activity = hass.states.get(mock_vacuum.entity_id) - assert vacuum_state.state == expected_state + assert activity.state == expected_state async def test_fan_speed(hass: HomeAssistant, config_flow_fixture: None) -> None: @@ -132,14 +154,16 @@ async def test_fan_speed(hass: HomeAssistant, config_flow_fixture: None) -> None async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, {"entity_id": mock_vacuum.entity_id, "fan_speed": "high"}, blocking=True, @@ -178,11 +202,13 @@ async def test_locate(hass: HomeAssistant, config_flow_fixture: None) -> None: async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_LOCATE, {"entity_id": mock_vacuum.entity_id}, blocking=True, @@ -227,11 +253,13 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SEND_COMMAND, { "entity_id": mock_vacuum.entity_id, @@ -244,37 +272,176 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N assert "test" in strings -async def test_supported_features_compat(hass: HomeAssistant) -> None: - """Test StateVacuumEntity using deprecated feature constants features.""" - - features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE +async def test_vacuum_not_log_deprecated_state_warning( + hass: HomeAssistant, + mock_vacuum_entity: MockVacuum, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test correctly using activity doesn't log issue or raise repair.""" + state = hass.states.get(mock_vacuum_entity.entity_id) + assert state is not None + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + not in caplog.text ) - class _LegacyConstantsStateVacuum(StateVacuumEntity): - _attr_supported_features = int(features) - _attr_fan_speed_list = ["silent", "normal", "pet hair"] - entity = _LegacyConstantsStateVacuum() - assert isinstance(entity.supported_features, int) - assert entity.supported_features == int(features) - assert entity.supported_features_compat is ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_vacuum_log_deprecated_state_warning_using_state_prop( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly using state property does log issue and raise repair.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + @property + def state(self) -> str: + """Return the state of the entity.""" + return VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", ) - assert entity.state_attributes == { - "battery_level": None, - "battery_icon": "mdi:battery-unknown", - "fan_speed": None, - } - assert entity.capability_attributes == { - "fan_speed_list": ["silent", "normal", "pet hair"] - } - assert entity._deprecated_supported_features_reported + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly using _attr_state attribute does log issue and raise repair.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + def start(self) -> None: + """Start cleaning.""" + self._attr_state = VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + not in caplog.text + ) + + await async_start(hass, entity.entity_id) + + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + in caplog.text + ) + caplog.clear() + await async_start(hass, entity.entity_id) + # Test we only log once + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + not in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_alarm_control_panel_deprecated_state_does_not_break_state( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test using _attr_state attribute does not break state.""" + + class MockLegacyVacuum(MockEntity, StateVacuumEntity): + """Mocked vacuum entity.""" + + _attr_supported_features = VacuumEntityFeature.STATE | VacuumEntityFeature.START + + def __init__(self, **values: Any) -> None: + """Initialize a mock vacuum entity.""" + super().__init__(**values) + self._attr_state = VacuumActivity.DOCKED + + def start(self) -> None: + """Start cleaning.""" + self._attr_state = VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "docked" + + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + { + "entity_id": entity.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "cleaning" diff --git a/tests/components/vacuum/test_intent.py b/tests/components/vacuum/test_intent.py index cf96d32ad49..9ede7dbc04e 100644 --- a/tests/components/vacuum/test_intent.py +++ b/tests/components/vacuum/test_intent.py @@ -37,6 +37,27 @@ async def test_start_vacuum_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": entity_id} +async def test_start_vacuum_without_name(hass: HomeAssistant) -> None: + """Test starting a vacuum without specifying the name.""" + await vacuum_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_vacuum" + hass.states.async_set(entity_id, STATE_IDLE) + calls = async_mock_service(hass, DOMAIN, SERVICE_START) + + response = await intent.async_handle( + hass, "test", vacuum_intent.INTENT_VACUUM_START, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_START + assert call.data == {"entity_id": entity_id} + + async def test_stop_vacuum_intent(hass: HomeAssistant) -> None: """Test HassTurnOff intent for vacuums.""" await vacuum_intent.async_setup_intents(hass) @@ -59,3 +80,24 @@ async def test_stop_vacuum_intent(hass: HomeAssistant) -> None: assert call.domain == DOMAIN assert call.service == SERVICE_RETURN_TO_BASE assert call.data == {"entity_id": entity_id} + + +async def test_stop_vacuum_without_name(hass: HomeAssistant) -> None: + """Test stopping a vacuum without specifying the name.""" + await vacuum_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_vacuum" + hass.states.async_set(entity_id, STATE_IDLE) + calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE) + + response = await intent.async_handle( + hass, "test", vacuum_intent.INTENT_VACUUM_RETURN_TO_BASE, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_RETURN_TO_BASE + assert call.data == {"entity_id": entity_id} diff --git a/tests/components/vacuum/test_reproduce_state.py b/tests/components/vacuum/test_reproduce_state.py index ff8da28e98c..dc5d81e8f08 100644 --- a/tests/components/vacuum/test_reproduce_state.py +++ b/tests/components/vacuum/test_reproduce_state.py @@ -9,18 +9,9 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, -) -from homeassistant.const import ( - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - STATE_IDLE, - STATE_OFF, - STATE_ON, - STATE_PAUSED, + VacuumActivity, ) +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state @@ -39,11 +30,11 @@ async def test_reproducing_states( hass.states.async_set( "vacuum.entity_on_fan", STATE_ON, {ATTR_FAN_SPEED: FAN_SPEED_LOW} ) - hass.states.async_set("vacuum.entity_cleaning", STATE_CLEANING, {}) - hass.states.async_set("vacuum.entity_docked", STATE_DOCKED, {}) - hass.states.async_set("vacuum.entity_idle", STATE_IDLE, {}) - hass.states.async_set("vacuum.entity_returning", STATE_RETURNING, {}) - hass.states.async_set("vacuum.entity_paused", STATE_PAUSED, {}) + hass.states.async_set("vacuum.entity_cleaning", VacuumActivity.CLEANING, {}) + hass.states.async_set("vacuum.entity_docked", VacuumActivity.DOCKED, {}) + hass.states.async_set("vacuum.entity_idle", VacuumActivity.IDLE, {}) + hass.states.async_set("vacuum.entity_returning", VacuumActivity.RETURNING, {}) + hass.states.async_set("vacuum.entity_paused", VacuumActivity.PAUSED, {}) turn_on_calls = async_mock_service(hass, "vacuum", SERVICE_TURN_ON) turn_off_calls = async_mock_service(hass, "vacuum", SERVICE_TURN_OFF) @@ -60,11 +51,11 @@ async def test_reproducing_states( State("vacuum.entity_off", STATE_OFF), State("vacuum.entity_on", STATE_ON), State("vacuum.entity_on_fan", STATE_ON, {ATTR_FAN_SPEED: FAN_SPEED_LOW}), - State("vacuum.entity_cleaning", STATE_CLEANING), - State("vacuum.entity_docked", STATE_DOCKED), - State("vacuum.entity_idle", STATE_IDLE), - State("vacuum.entity_returning", STATE_RETURNING), - State("vacuum.entity_paused", STATE_PAUSED), + State("vacuum.entity_cleaning", VacuumActivity.CLEANING), + State("vacuum.entity_docked", VacuumActivity.DOCKED), + State("vacuum.entity_idle", VacuumActivity.IDLE), + State("vacuum.entity_returning", VacuumActivity.RETURNING), + State("vacuum.entity_paused", VacuumActivity.PAUSED), ], ) @@ -95,11 +86,11 @@ async def test_reproducing_states( State("vacuum.entity_off", STATE_ON), State("vacuum.entity_on", STATE_OFF), State("vacuum.entity_on_fan", STATE_ON, {ATTR_FAN_SPEED: FAN_SPEED_HIGH}), - State("vacuum.entity_cleaning", STATE_PAUSED), - State("vacuum.entity_docked", STATE_CLEANING), - State("vacuum.entity_idle", STATE_DOCKED), - State("vacuum.entity_returning", STATE_CLEANING), - State("vacuum.entity_paused", STATE_IDLE), + State("vacuum.entity_cleaning", VacuumActivity.PAUSED), + State("vacuum.entity_docked", VacuumActivity.CLEANING), + State("vacuum.entity_idle", VacuumActivity.DOCKED), + State("vacuum.entity_returning", VacuumActivity.CLEANING), + State("vacuum.entity_paused", VacuumActivity.IDLE), # Should not raise State("vacuum.non_existing", STATE_ON), ], diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 432fcea10db..5e81a3f8a36 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -156,12 +156,18 @@ async def test_flow_usb(hass: HomeAssistant) -> None: user_input={}, ) assert result + assert result["result"].unique_id == "0B1B:10CF_1234_Velleman_Velbus VMB1USB" assert result.get("type") is FlowResultType.CREATE_ENTRY - # test an already configured discovery + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: + """Test we abort if Velbus USB discovbery aborts in case it is already setup.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_PORT: PORT_SERIAL}, + unique_id="0B1B:10CF_1234_Velleman_Velbus VMB1USB", ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 36694ae3ef6..2e7fe9ac1bb 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -428,10 +428,10 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6493, + 'max_color_temp_kelvin': 6500, 'max_mireds': 370, - 'min_color_temp_kelvin': 2702, - 'min_mireds': 154, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, 'supported_color_modes': list([ , ]), @@ -473,10 +473,10 @@ 'color_temp_kelvin': None, 'friendly_name': 'Temperature Light', 'hs_color': None, - 'max_color_temp_kelvin': 6493, + 'max_color_temp_kelvin': 6500, 'max_mireds': 370, - 'min_color_temp_kelvin': 2702, - 'min_mireds': 154, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ , diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index ae9b05389c7..0b1dcef5a29 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4731,6 +4731,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'ViCare', 'version': 1, diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 3e7b0c83c70..51151ae8f42 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -112,14 +112,6 @@ MOCK_OPTIONS = { CONF_VOLUME_STEP: VOLUME_STEP, } -MOCK_IMPORT_VALID_TV_CONFIG = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, -} - MOCK_TV_WITH_INCLUDE_CONFIG = { CONF_NAME: NAME, CONF_HOST: HOST, @@ -147,23 +139,6 @@ MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG = { CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, } -MOCK_SPEAKER_APPS_FAILURE = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.SPEAKER, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, - CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, -} - -MOCK_TV_APPS_FAILURE = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, - CONF_APPS: None, -} MOCK_TV_APPS_WITH_VALID_APPS_CONFIG = { CONF_HOST: HOST, diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 42d4394ca80..2ef7c18bd04 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -3,30 +3,20 @@ import dataclasses import pytest -import voluptuous as vol from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.components.vizio.config_flow import _get_config_schema from homeassistant.components.vizio.const import ( CONF_APPS, CONF_APPS_TO_INCLUDE_OR_EXCLUDE, - CONF_INCLUDE, CONF_VOLUME_STEP, - DEFAULT_NAME, - DEFAULT_VOLUME_STEP, DOMAIN, - VIZIO_SCHEMA, -) -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_IMPORT, - SOURCE_USER, - SOURCE_ZEROCONF, ) +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, CONF_HOST, + CONF_INCLUDE, CONF_NAME, CONF_PIN, ) @@ -38,14 +28,11 @@ from .const import ( CURRENT_APP, HOST, HOST2, - MOCK_IMPORT_VALID_TV_CONFIG, MOCK_INCLUDE_APPS, MOCK_INCLUDE_NO_APPS, MOCK_PIN_CONFIG, MOCK_SPEAKER_CONFIG, MOCK_TV_CONFIG_NO_TOKEN, - MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, - MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_USER_VALID_TV_CONFIG, MOCK_ZEROCONF_SERVICE_INFO, NAME, @@ -370,297 +357,6 @@ async def test_user_ignore(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_flow_minimum_fields(hass: HomeAssistant) -> None: - """Test import config flow with minimum fields.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)( - {CONF_HOST: HOST, CONF_DEVICE_CLASS: MediaPlayerDeviceClass.SPEAKER} - ), - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_NAME] == DEFAULT_NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.SPEAKER - assert result["data"][CONF_VOLUME_STEP] == DEFAULT_VOLUME_STEP - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_flow_all_fields(hass: HomeAssistant) -> None: - """Test import config flow with all fields.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG), - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN - assert result["data"][CONF_VOLUME_STEP] == VOLUME_STEP - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_entity_already_configured(hass: HomeAssistant) -> None: - """Test entity is already configured during import setup.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - options={CONF_VOLUME_STEP: VOLUME_STEP}, - ) - entry.add_to_hass(hass) - fail_entry = vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG.copy()) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=fail_entry - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured_device" - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_update_options(hass: HomeAssistant) -> None: - """Test import config flow with updated options.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].options == {CONF_VOLUME_STEP: DEFAULT_VOLUME_STEP} - assert result["type"] is FlowResultType.CREATE_ENTRY - entry_id = result["result"].entry_id - - updated_config = MOCK_SPEAKER_CONFIG.copy() - updated_config[CONF_VOLUME_STEP] = VOLUME_STEP + 1 - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(updated_config), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - config_entry = hass.config_entries.async_get_entry(entry_id) - assert config_entry.options[CONF_VOLUME_STEP] == VOLUME_STEP + 1 - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_update_name_and_apps(hass: HomeAssistant) -> None: - """Test import config flow with updated name and apps.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].data[CONF_NAME] == NAME - assert result["type"] is FlowResultType.CREATE_ENTRY - entry_id = result["result"].entry_id - - updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy() - updated_config[CONF_NAME] = NAME2 - updated_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(updated_config), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - config_entry = hass.config_entries.async_get_entry(entry_id) - assert config_entry.data[CONF_NAME] == NAME2 - assert config_entry.data[CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]} - assert config_entry.options[CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]} - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_update_remove_apps(hass: HomeAssistant) -> None: - """Test import config flow with removed apps.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_EXCLUDE_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].data[CONF_NAME] == NAME - assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = hass.config_entries.async_get_entry(result["result"].entry_id) - assert CONF_APPS in config_entry.data - assert CONF_APPS in config_entry.options - - updated_config = MOCK_TV_WITH_EXCLUDE_CONFIG.copy() - updated_config.pop(CONF_APPS) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(updated_config), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - assert CONF_APPS not in config_entry.data - assert CONF_APPS not in config_entry.options - - -@pytest.mark.usefixtures( - "vizio_connect", "vizio_bypass_setup", "vizio_complete_pairing" -) -async def test_import_needs_pairing(hass: HomeAssistant) -> None: - """Test pairing config flow when access token not provided for tv during import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_TV_CONFIG_NO_TOKEN - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_TV_CONFIG_NO_TOKEN - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pair_tv" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_PIN_CONFIG - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing_complete_import" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - - -@pytest.mark.usefixtures( - "vizio_connect", "vizio_bypass_setup", "vizio_complete_pairing" -) -async def test_import_with_apps_needs_pairing(hass: HomeAssistant) -> None: - """Test pairing config flow when access token not provided for tv but apps are included during import.""" - import_config = MOCK_TV_CONFIG_NO_TOKEN.copy() - import_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=import_config - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - # Mock inputting info without apps to make sure apps get stored - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=_get_config_schema(MOCK_TV_CONFIG_NO_TOKEN)(MOCK_TV_CONFIG_NO_TOKEN), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pair_tv" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_PIN_CONFIG - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing_complete_import" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP] - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_additional_configs(hass: HomeAssistant) -> None: - """Test import config flow with additional configs defined in CONF_APPS.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].data[CONF_NAME] == NAME - assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = hass.config_entries.async_get_entry(result["result"].entry_id) - assert CONF_APPS in config_entry.data - assert CONF_APPS not in config_entry.options - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that error is logged when import config has an error.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - options={CONF_VOLUME_STEP: VOLUME_STEP}, - unique_id=UNIQUE_ID, - ) - entry.add_to_hass(hass) - fail_entry = MOCK_SPEAKER_CONFIG.copy() - fail_entry[CONF_HOST] = "0.0.0.0" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(fail_entry), - ) - - assert result["type"] is FlowResultType.FORM - - # Ensure error gets logged - vizio_log_list = [ - log - for log in caplog.records - if log.name == "homeassistant.components.vizio.config_flow" - ] - assert len(vizio_log_list) == 1 - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_ignore(hass: HomeAssistant) -> None: - """Test import config flow doesn't throw an error when there's an existing ignored source.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_SPEAKER_CONFIG, - options={CONF_VOLUME_STEP: VOLUME_STEP}, - source=SOURCE_IGNORE, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - - @pytest.mark.usefixtures( "vizio_connect", "vizio_bypass_setup", "vizio_guess_device_type" ) @@ -854,26 +550,3 @@ async def test_zeroconf_flow_already_configured_hostname(hass: HomeAssistant) -> # Flow should abort because device is already setup assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup", "vizio_hostname_check") -async def test_import_flow_already_configured_hostname(hass: HomeAssistant) -> None: - """Test entity is already configured during import setup when existing entry uses hostname.""" - config = MOCK_SPEAKER_CONFIG.copy() - config[CONF_HOST] = "hostname" - entry = MockConfigEntry( - domain=DOMAIN, data=config, options={CONF_VOLUME_STEP: VOLUME_STEP} - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - ) - - # Flow should abort because device was updated - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - - assert entry.data[CONF_HOST] == HOST diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index c2b19377809..e004255ec6d 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID @@ -15,16 +14,6 @@ from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_setup_component(hass: HomeAssistant) -> None: - """Test component setup.""" - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: MOCK_USER_VALID_TV_CONFIG} - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 - - @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_tv_load_and_unload(hass: HomeAssistant) -> None: """Test loading and unloading TV entry.""" diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 12e19077c8e..a76dfa3fa2d 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -19,7 +19,6 @@ from pyvizio.const import ( MAX_VOLUME, UNKNOWN_APP, ) -import voluptuous as vol from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -42,7 +41,6 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_UP, MediaPlayerDeviceClass, ) -from homeassistant.components.vizio import validate_apps from homeassistant.components.vizio.const import ( CONF_ADDITIONAL_CONFIGS, CONF_APPS, @@ -50,7 +48,6 @@ from homeassistant.components.vizio.const import ( DEFAULT_VOLUME_STEP, DOMAIN, SERVICE_UPDATE_SETTING, - VIZIO_SCHEMA, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -69,9 +66,7 @@ from .const import ( EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, - MOCK_SPEAKER_APPS_FAILURE, MOCK_SPEAKER_CONFIG, - MOCK_TV_APPS_FAILURE, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_TV_WITH_INCLUDE_CONFIG, @@ -155,7 +150,7 @@ async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool | None) -> config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID, ) @@ -181,7 +176,7 @@ async def _test_setup_speaker( config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), + data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID, ) @@ -215,7 +210,7 @@ async def _cm_for_test_setup_tv_with_apps( ) -> AsyncIterator[None]: """Context manager to setup test for Vizio TV with support for apps.""" config_entry = MockConfigEntry( - domain=DOMAIN, data=vol.Schema(VIZIO_SCHEMA)(device_config), unique_id=UNIQUE_ID + domain=DOMAIN, data=device_config, unique_id=UNIQUE_ID ) async with _cm_for_test_setup_without_apps( @@ -641,15 +636,6 @@ async def test_setup_with_apps_additional_apps_config( assert not service_call2.called -def test_invalid_apps_config(hass: HomeAssistant) -> None: - """Test that schema validation fails on certain conditions.""" - with pytest.raises(vol.Invalid): - vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_TV_APPS_FAILURE) - - with pytest.raises(vol.Invalid): - vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_SPEAKER_APPS_FAILURE) - - @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") async def test_setup_with_unknown_app_config( hass: HomeAssistant, @@ -687,7 +673,7 @@ async def test_setup_tv_without_mute(hass: HomeAssistant) -> None: """Test Vizio TV entity setup when mute property isn't returned by Vizio API.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID, ) @@ -742,7 +728,7 @@ async def test_vizio_update_with_apps_on_input(hass: HomeAssistant) -> None: """Test a vizio TV with apps that is on a TV input.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID, ) await _add_config_entry_to_hass(hass, config_entry) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index 9adf32b339d..fc6bbd01398 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -29,11 +29,13 @@ DEVICE_DATA_QUERY = { mac="xx:xx:xx:xx:xx:xx", type="laptop", wifi="2.4G", - ) + ), } +SERIAL = "m123456789" + SENSOR_DATA_QUERY = { - "sys_serial_number": "M123456789", + "sys_serial_number": SERIAL, "sys_firmware_version": "XF6_4.0.05.04", "sys_bootloader_version": "0220", "sys_hardware_version": "RHG3006 v1", diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index c258b14dc2d..dd268f4ed1a 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -35,6 +35,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py new file mode 100644 index 00000000000..8b9b0753caa --- /dev/null +++ b/tests/components/vodafone_station/test_button.py @@ -0,0 +1,56 @@ +"""Tests for Vodafone Station button platform.""" + +from unittest.mock import patch + +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, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY, SERIAL + +from tests.common import MockConfigEntry + + +async def test_button(hass: HomeAssistant, entity_registry: EntityRegistry) -> None: + """Test device restart button.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + with ( + patch("aiovodafone.api.VodafoneStationSercommApi.login"), + patch( + "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", + return_value=DEVICE_DATA_QUERY, + ), + patch( + "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", + return_value=SENSOR_DATA_QUERY, + ), + patch( + "aiovodafone.api.VodafoneStationSercommApi.restart_router", + ) as mock_router_restart, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = f"button.vodafone_station_{SERIAL}_restart" + + # restart button + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{SERIAL}_reboot" + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_router_restart.call_count == 1 diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 0c5651058ed..78efd94ef8e 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -9,8 +9,6 @@ import pytest import voluptuous as vol from homeassistant.components.water_heater import ( - ATTR_OPERATION_LIST, - ATTR_OPERATION_MODE, DOMAIN, SERVICE_SET_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, @@ -206,28 +204,3 @@ async def test_operation_mode_validation( ) await hass.async_block_till_done() water_heater_entity.set_operation_mode.assert_has_calls([mock.call("eco")]) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockWaterHeaterEntity(WaterHeaterEntity): - _attr_operation_list = ["mode1", "mode2"] - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_current_operation = "mode1" - _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE.value - - entity = MockWaterHeaterEntity() - entity.hass = hass - assert entity.supported_features_compat is WaterHeaterEntityFeature(2) - assert "MockWaterHeaterEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "WaterHeaterEntityFeature.OPERATION_MODE" in caplog.text - caplog.clear() - assert entity.supported_features_compat is WaterHeaterEntityFeature(2) - assert "is using deprecated supported features values" not in caplog.text - assert entity.state_attributes[ATTR_OPERATION_MODE] == "mode1" - assert entity.capability_attributes[ATTR_OPERATION_LIST] == ["mode1", "mode2"] diff --git a/tests/components/watergate/__init__.py b/tests/components/watergate/__init__.py new file mode 100644 index 00000000000..c69129e4720 --- /dev/null +++ b/tests/components/watergate/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Watergate integration.""" + +from homeassistant.core import HomeAssistant + + +async def init_integration(hass: HomeAssistant, mock_entry) -> None: + """Set up the Watergate integration in Home Assistant.""" + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/watergate/conftest.py b/tests/components/watergate/conftest.py new file mode 100644 index 00000000000..d29b90431a4 --- /dev/null +++ b/tests/components/watergate/conftest.py @@ -0,0 +1,77 @@ +"""Fixtures for watergate platform tests.""" + +from collections.abc import Generator + +import pytest + +from homeassistant.components.watergate.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS + +from .const import ( + DEFAULT_DEVICE_STATE, + DEFAULT_SERIAL_NUMBER, + MOCK_CONFIG, + MOCK_WEBHOOK_ID, +) + +from tests.common import AsyncMock, MockConfigEntry, patch + + +@pytest.fixture +def mock_watergate_client() -> Generator[AsyncMock]: + """Fixture to mock WatergateLocalApiClient.""" + with ( + patch( + "homeassistant.components.watergate.WatergateLocalApiClient", + autospec=True, + ) as mock_client_main, + patch( + "homeassistant.components.watergate.config_flow.WatergateLocalApiClient", + new=mock_client_main, + ), + ): + mock_client_instance = mock_client_main.return_value + + mock_client_instance.async_get_device_state = AsyncMock( + return_value=DEFAULT_DEVICE_STATE + ) + yield mock_client_instance + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.watergate.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_webhook_id_generation() -> Generator[None]: + """Fixture to mock webhook_id generation.""" + with patch( + "homeassistant.components.watergate.config_flow.webhook_generate_id", + return_value=MOCK_WEBHOOK_ID, + ): + yield + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Create full mocked entry to be used in config_flow tests.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sonic", + data=MOCK_CONFIG, + entry_id="12345", + unique_id=DEFAULT_SERIAL_NUMBER, + ) + + +@pytest.fixture +def user_input() -> dict[str, str]: + """Create user input for config_flow tests.""" + return { + CONF_IP_ADDRESS: "192.168.1.100", + } diff --git a/tests/components/watergate/const.py b/tests/components/watergate/const.py new file mode 100644 index 00000000000..4297b3321ad --- /dev/null +++ b/tests/components/watergate/const.py @@ -0,0 +1,27 @@ +"""Constants for the Watergate tests.""" + +from watergate_local_api.models import DeviceState + +from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, CONF_WEBHOOK_ID + +MOCK_WEBHOOK_ID = "webhook_id" + +MOCK_CONFIG = { + CONF_NAME: "Sonic", + CONF_IP_ADDRESS: "http://localhost", + CONF_WEBHOOK_ID: MOCK_WEBHOOK_ID, +} + +DEFAULT_SERIAL_NUMBER = "a63182948ce2896a" + +DEFAULT_DEVICE_STATE = DeviceState( + "open", + "on", + True, + True, + "battery", + "1.0.0", + 100, + {"volume": 1.2, "duration": 100}, + DEFAULT_SERIAL_NUMBER, +) diff --git a/tests/components/watergate/snapshots/test_valve.ambr b/tests/components/watergate/snapshots/test_valve.ambr new file mode 100644 index 00000000000..1df1a0c748d --- /dev/null +++ b/tests/components/watergate/snapshots/test_valve.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_change_valve_state_snapshot + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Sonic', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.sonic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/watergate/test_config_flow.py b/tests/components/watergate/test_config_flow.py new file mode 100644 index 00000000000..176047f5e23 --- /dev/null +++ b/tests/components/watergate/test_config_flow.py @@ -0,0 +1,107 @@ +"""Tests for the Watergate config flow.""" + +from collections.abc import Generator + +import pytest +from watergate_local_api import WatergateApiException + +from homeassistant.components.watergate.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID +from homeassistant.data_entry_flow import FlowResultType + +from .const import DEFAULT_DEVICE_STATE, DEFAULT_SERIAL_NUMBER, MOCK_WEBHOOK_ID + +from tests.common import AsyncMock, HomeAssistant, MockConfigEntry + + +async def test_step_user_form( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + mock_webhook_id_generation: Generator[None], + user_input: dict[str, str], +) -> None: + """Test checking if registration form works end to end.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert CONF_IP_ADDRESS in result["data_schema"].schema + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sonic" + assert result["data"] == {**user_input, CONF_WEBHOOK_ID: MOCK_WEBHOOK_ID} + assert result["result"].unique_id == DEFAULT_SERIAL_NUMBER + + +@pytest.mark.parametrize( + "client_result", + [AsyncMock(return_value=None), AsyncMock(side_effect=WatergateApiException)], +) +async def test_step_user_form_with_exception( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + user_input: dict[str, str], + client_result: AsyncMock, + mock_webhook_id_generation: Generator[None], +) -> None: + """Test checking if errors will be displayed when Exception is thrown while checking device state.""" + mock_watergate_client.async_get_device_state = client_result + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"][CONF_IP_ADDRESS] == "cannot_connect" + + mock_watergate_client.async_get_device_state = AsyncMock( + return_value=DEFAULT_DEVICE_STATE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sonic" + assert result["data"] == {**user_input, CONF_WEBHOOK_ID: MOCK_WEBHOOK_ID} + + +async def test_abort_if_id_is_not_unique( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + mock_entry: MockConfigEntry, + user_input: dict[str, str], +) -> None: + """Test checking if we will inform user that this entity is already registered.""" + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert CONF_IP_ADDRESS in result["data_schema"].schema + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/watergate/test_init.py b/tests/components/watergate/test_init.py new file mode 100644 index 00000000000..71eb99d6470 --- /dev/null +++ b/tests/components/watergate/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the Watergate integration init module.""" + +from collections.abc import Generator +from unittest.mock import patch + +from homeassistant.components.valve import ValveState +from homeassistant.components.watergate.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration +from .const import MOCK_WEBHOOK_ID + +from tests.common import ANY, AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator + + +async def test_async_setup_entry( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test setting up the Watergate integration.""" + hass.config.internal_url = "http://hassio.local" + + with ( + patch("homeassistant.components.watergate.async_register") as mock_webhook, + ): + await init_integration(hass, mock_entry) + + assert mock_entry.state is ConfigEntryState.LOADED + + mock_webhook.assert_called_once_with( + hass, + DOMAIN, + "Watergate", + MOCK_WEBHOOK_ID, + ANY, + ) + mock_watergate_client.async_set_webhook_url.assert_called_once_with( + f"http://hassio.local/api/webhook/{MOCK_WEBHOOK_ID}" + ) + mock_watergate_client.async_get_device_state.assert_called_once() + + +async def test_handle_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test handling webhook events.""" + await init_integration(hass, mock_entry) + + entity_id = "valve.sonic" + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPEN + + valve_change_data = { + "type": "valve", + "data": {"state": "closed"}, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=valve_change_data) + + await hass.async_block_till_done() # Ensure the webhook is processed + + assert hass.states.get(entity_id).state == ValveState.CLOSED + + valve_change_data = { + "type": "valve", + "data": {"state": "open"}, + } + + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=valve_change_data) + + await hass.async_block_till_done() # Ensure the webhook is processed + + assert hass.states.get(entity_id).state == ValveState.OPEN diff --git a/tests/components/watergate/test_valve.py b/tests/components/watergate/test_valve.py new file mode 100644 index 00000000000..b22f6967665 --- /dev/null +++ b/tests/components/watergate/test_valve.py @@ -0,0 +1,72 @@ +"""Tests for the Watergate valve platform.""" + +from collections.abc import Generator + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import AsyncMock, MockConfigEntry + + +async def test_change_valve_state_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_watergate_client: Generator[AsyncMock], + mock_entry: MockConfigEntry, +) -> None: + """Test entities become unavailable after failed update.""" + await init_integration(hass, mock_entry) + + entity_id = "valve.sonic" + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPEN + assert registered_entity == snapshot + + +async def test_change_valve_state( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + mock_entry: MockConfigEntry, +) -> None: + """Test entities become unavailable after failed update.""" + await init_integration(hass, mock_entry) + + entity_id = "valve.sonic" + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.CLOSING + + mock_watergate_client.async_set_valve_state.assert_called_once_with("closed") + mock_watergate_client.async_set_valve_state.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPENING + + mock_watergate_client.async_set_valve_state.assert_called_once_with("open") diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index 0c137acc36b..3cc5e1d6f66 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -27,6 +27,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index 8299b0eafba..c64fa212a98 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -253,6 +253,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 3d7cb00e021..7f54e940966 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -61,5 +61,6 @@ async def test_diagnostics( "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), "discovery_keys": {}, + "subentries": [], }, } diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 48be2823750..4deddeaba94 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant import ( ) from homeassistant.components.light import ( ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, ColorMode, @@ -116,7 +116,7 @@ async def test_light_update_entity( blocking=True, ) state = hass.states.get(wemo_entity.entity_id) - assert state.attributes.get(ATTR_COLOR_TEMP) == 432 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2314 assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.COLOR_TEMP] assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.COLOR_TEMP assert state.state == STATE_ON diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index c60ce17b952..ee8abe04bf1 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -38,6 +38,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 937502d4d6c..0d99b0596e3 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ '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, @@ -70,10 +74,14 @@ '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, @@ -110,10 +118,14 @@ '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, @@ -150,10 +162,14 @@ '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, @@ -190,10 +206,14 @@ '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, diff --git a/tests/components/wiz/test_light.py b/tests/components/wiz/test_light.py index 1fb87b30a5f..5c74d407238 100644 --- a/tests/components/wiz/test_light.py +++ b/tests/components/wiz/test_light.py @@ -4,7 +4,7 @@ from pywizlight import PilotBuilder from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -91,7 +91,7 @@ async def test_rgbww_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6535, ATTR_BRIGHTNESS: 128}, blocking=True, ) pilot: PilotBuilder = bulb.turn_on.mock_calls[0][1][0] @@ -99,7 +99,7 @@ async def test_rgbww_light(hass: HomeAssistant) -> None: await async_push_update(hass, bulb, {"mac": FAKE_MAC, **pilot.pilot_params}) state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 153 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 6535 bulb.turn_on.reset_mock() await hass.services.async_call( @@ -148,7 +148,7 @@ async def test_rgbw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6535, ATTR_BRIGHTNESS: 128}, blocking=True, ) pilot: PilotBuilder = bulb.turn_on.mock_calls[0][1][0] @@ -162,7 +162,7 @@ async def test_turnable_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6535, ATTR_BRIGHTNESS: 128}, blocking=True, ) pilot: PilotBuilder = bulb.turn_on.mock_calls[0][1][0] @@ -171,7 +171,7 @@ async def test_turnable_light(hass: HomeAssistant) -> None: await async_push_update(hass, bulb, {"mac": FAKE_MAC, **pilot.pilot_params}) state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 153 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 6535 async def test_old_firmware_dimmable_light(hass: HomeAssistant) -> None: diff --git a/tests/components/workday/snapshots/test_diagnostics.ambr b/tests/components/workday/snapshots/test_diagnostics.ambr index f41b86b7f6d..e7331b911a8 100644 --- a/tests/components/workday/snapshots/test_diagnostics.ambr +++ b/tests/components/workday/snapshots/test_diagnostics.ambr @@ -40,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index bdead0f2028..d288c531407 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -36,10 +36,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -82,10 +86,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -127,10 +135,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Test Satellite', 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Test Satellite', 'type': , 'version': 1, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 76321a1a0a8..e58f21e387b 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -21,8 +21,7 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_ERROR, + VacuumActivity, ) from homeassistant.components.xiaomi_miio.const import ( CONF_FLOW_TYPE, @@ -264,7 +263,7 @@ async def test_xiaomi_vacuum_services( # Check state attributes state = hass.states.get(entity_id) - assert state.state == STATE_ERROR + assert state.state == VacuumActivity.ERROR assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 assert state.attributes.get(ATTR_ERROR) == "Error message" assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-80" @@ -450,7 +449,7 @@ async def test_xiaomi_specific_services( # Check state attributes state = hass.states.get(entity_id) - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 assert state.attributes.get(ATTR_ERROR) is None assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-30" diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 7a7abcac67c..91c64c7a7a7 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -20,7 +20,6 @@ from tests.common import MockConfigEntry, load_fixture ENTRY_CONFIG = { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "1", } OPTIONS_CONFIG = {"lock_code_digits": 6} @@ -35,51 +34,64 @@ async def patch_platform_constant() -> list[Platform]: @pytest.fixture async def load_config_entry( hass: HomeAssistant, - get_data: YaleSmartAlarmData, - get_all_data: YaleSmartAlarmData, + get_client: Mock, load_platforms: list[Platform], ) -> tuple[MockConfigEntry, Mock]: """Set up the Yale Smart Living integration in Home Assistant.""" with patch("homeassistant.components.yale_smart_alarm.PLATFORMS", load_platforms): config_entry = MockConfigEntry( + title=ENTRY_CONFIG["username"], domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", unique_id="username", - version=1, + version=2, + minor_version=2, ) config_entry.add_to_hass(hass) - - cycle = get_data.cycle["data"] - data = {"data": cycle["device_status"]} - with patch( "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", - autospec=True, - ) as mock_client_class: - client = mock_client_class.return_value - client.auth = Mock() - client.auth.get_authenticated = Mock(return_value=data) - client.auth.post_authenticated = Mock(return_value={"code": "000"}) - client.auth.put_authenticated = Mock(return_value={"code": "000"}) - client.lock_api = YaleDoorManAPI(client.auth) - locks = [ - YaleLock(device, lock_api=client.lock_api) - for device in cycle["device_status"] - if device["type"] == YaleLock.DEVICE_TYPE - ] - client.get_locks.return_value = locks - client.get_all.return_value = get_all_data - client.get_information.return_value = get_data - client.get_armed_status.return_value = YALE_STATE_ARM_FULL - + return_value=get_client, + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return (config_entry, client) + return (config_entry, get_client) + + +@pytest.fixture(name="get_client") +async def mock_client( + get_data: YaleSmartAlarmData, + get_all_data: YaleSmartAlarmData, +) -> Mock: + """Mock the Yale client.""" + cycle = get_data.cycle["data"] + data = {"data": cycle["device_status"]} + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.auth = Mock() + client.auth.get_authenticated = Mock(return_value=data) + client.auth.post_authenticated = Mock(return_value={"code": "000"}) + client.auth.put_authenticated = Mock(return_value={"code": "000"}) + client.lock_api = YaleDoorManAPI(client.auth) + locks = [ + YaleLock(device, lock_api=client.lock_api) + for device in cycle["device_status"] + if device["type"] == YaleLock.DEVICE_TYPE + ] + client.get_locks.return_value = locks + client.get_all.return_value = get_all_data + client.get_information.return_value = get_data + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + + return client @pytest.fixture(name="loaded_fixture", scope="package") diff --git a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr index 749e62252f3..fcdb7baca03 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.yale_smart_alarm-entry] +# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.test_username-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'alarm_control_panel', 'entity_category': None, - 'entity_id': 'alarm_control_panel.yale_smart_alarm', + 'entity_id': 'alarm_control_panel.test_username', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -32,17 +32,17 @@ 'unit_of_measurement': None, }) # --- -# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.yale_smart_alarm-state] +# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.test_username-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'friendly_name': 'Yale Smart Alarm', + 'friendly_name': 'test-username', 'supported_features': , }), 'context': , - 'entity_id': 'alarm_control_panel.yale_smart_alarm', + 'entity_id': 'alarm_control_panel.test_username', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr index ed7e847439c..e519a880de9 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr @@ -281,7 +281,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_battery-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -293,7 +293,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_battery', + 'entity_id': 'binary_sensor.test_username_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -314,21 +314,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_battery-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Battery', + 'friendly_name': 'test-username Battery', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_battery', + 'entity_id': 'binary_sensor.test_username_battery', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_jam-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_jam-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -340,7 +340,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_jam', + 'entity_id': 'binary_sensor.test_username_jam', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -361,21 +361,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_jam-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_jam-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Jam', + 'friendly_name': 'test-username Jam', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_jam', + 'entity_id': 'binary_sensor.test_username_jam', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_power_loss-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_power_loss-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -387,7 +387,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_power_loss', + 'entity_id': 'binary_sensor.test_username_power_loss', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -408,21 +408,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_power_loss-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_power_loss-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Power loss', + 'friendly_name': 'test-username Power loss', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_power_loss', + 'entity_id': 'binary_sensor.test_username_power_loss', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_tamper-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_tamper-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -434,7 +434,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_tamper', + 'entity_id': 'binary_sensor.test_username_tamper', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -455,14 +455,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_tamper-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_tamper-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Tamper', + 'friendly_name': 'test-username Tamper', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_tamper', + 'entity_id': 'binary_sensor.test_username_tamper', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yale_smart_alarm/snapshots/test_button.ambr b/tests/components/yale_smart_alarm/snapshots/test_button.ambr index 8abceb0affa..951caced170 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_button.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_button[load_platforms0][button.yale_smart_alarm_panic_button-entry] +# name: test_button[load_platforms0][button.test_username_panic_button-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.yale_smart_alarm_panic_button', + 'entity_id': 'button.test_username_panic_button', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[load_platforms0][button.yale_smart_alarm_panic_button-state] +# name: test_button[load_platforms0][button.test_username_panic_button-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Yale Smart Alarm Panic button', + 'friendly_name': 'test-username Panic button', }), 'context': , - 'entity_id': 'button.yale_smart_alarm_panic_button', + 'entity_id': 'button.test_username_panic_button', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yale_smart_alarm/test_alarm_control_panel.py b/tests/components/yale_smart_alarm/test_alarm_control_panel.py index 4e8330df071..0280223b72a 100644 --- a/tests/components/yale_smart_alarm/test_alarm_control_panel.py +++ b/tests/components/yale_smart_alarm/test_alarm_control_panel.py @@ -2,16 +2,27 @@ from __future__ import annotations +from copy import deepcopy from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from yalesmartalarmclient import YaleSmartAlarmData -from homeassistant.const import Platform +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + AlarmControlPanelState, +) +from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -27,3 +38,111 @@ async def test_alarm_control_panel( """Test the Yale Smart Alarm alarm_control_panel.""" entry = load_config_entry[0] await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.ALARM_CONTROL_PANEL]], +) +async def test_alarm_control_panel_service_calls( + hass: HomeAssistant, + get_data: YaleSmartAlarmData, + load_config_entry: tuple[MockConfigEntry, Mock], +) -> None: + """Test the Yale Smart Alarm alarm_control_panel action calls.""" + + client = load_config_entry[1] + + data = deepcopy(get_data.cycle) + data["data"] = data["data"].pop("device_status") + + client.auth.get_authenticated = Mock(return_value=data) + client.disarm = Mock(return_value=True) + client.arm_partial = Mock(return_value=True) + client.arm_full = Mock(return_value=True) + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + client.disarm.assert_called_once() + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.DISARMED + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + client.arm_partial.assert_called_once() + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_HOME + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + client.arm_full.assert_called_once() + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + client.disarm = Mock(side_effect=ConnectionError("no connection")) + + with pytest.raises( + HomeAssistantError, + match="Could not set alarm for test-username: no connection", + ): + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + client.disarm = Mock(return_value=False) + + with pytest.raises( + HomeAssistantError, + match="Could not change alarm, check system ready for arming", + ): + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.ALARM_CONTROL_PANEL]], +) +async def test_alarm_control_panel_not_available( + hass: HomeAssistant, + get_data: YaleSmartAlarmData, + load_config_entry: tuple[MockConfigEntry, Mock], + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Yale Smart Alarm alarm_control_panel not being available.""" + + client = load_config_entry[1] + client.get_armed_status = Mock(return_value=None) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/yale_smart_alarm/test_button.py b/tests/components/yale_smart_alarm/test_button.py index ad6074345d3..cb28e60ab22 100644 --- a/tests/components/yale_smart_alarm/test_button.py +++ b/tests/components/yale_smart_alarm/test_button.py @@ -37,7 +37,7 @@ async def test_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.yale_smart_alarm_panic_button", + ATTR_ENTITY_ID: "button.test_username_panic_button", }, blocking=True, ) @@ -50,7 +50,7 @@ async def test_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.yale_smart_alarm_panic_button", + ATTR_ENTITY_ID: "button.test_username_panic_button", }, blocking=True, ) diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index e5b59f79463..0b008d4c696 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError @@ -48,7 +48,6 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", } assert len(mock_setup_entry.mock_calls) == 1 @@ -112,7 +111,6 @@ async def test_form_invalid_auth( assert result2["data"] == { "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", } @@ -120,15 +118,16 @@ async def test_form_invalid_auth( async def test_reauth_flow(hass: HomeAssistant) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -159,7 +158,6 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert entry.data == { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "1", } @@ -181,15 +179,16 @@ async def test_reauth_flow_error( ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -234,7 +233,6 @@ async def test_reauth_flow_error( assert entry.data == { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "1", } @@ -242,15 +240,16 @@ async def test_reauth_flow_error( async def test_reconfigure(hass: HomeAssistant) -> None: """Test reconfigure config flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -281,7 +280,6 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert entry.data == { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "2", } @@ -289,27 +287,29 @@ async def test_reconfigure(hass: HomeAssistant) -> None: async def test_reconfigure_username_exist(hass: HomeAssistant) -> None: """Test reconfigure config flow abort other username already exist.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) entry2 = MockConfigEntry( + title="other-username", domain=DOMAIN, unique_id="other-username", data={ "username": "other-username", "password": "test-password", - "name": "Yale Smart Alarm 2", "area_id": "1", }, version=2, + minor_version=2, ) entry2.add_to_hass(hass) @@ -362,7 +362,6 @@ async def test_reconfigure_username_exist(hass: HomeAssistant) -> None: assert result["reason"] == "reconfigure_successful" assert entry.data == { "username": "other-new-username", - "name": "Yale Smart Alarm", "password": "test-password", "area_id": "1", } @@ -382,15 +381,16 @@ async def test_reconfigure_flow_error( ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -438,49 +438,34 @@ async def test_reconfigure_flow_error( assert result["reason"] == "reconfigure_successful" assert entry.data == { "username": "test-username", - "name": "Yale Smart Alarm", "password": "new-test-password", "area_id": "1", } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], +) -> None: """Test options config flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test-username", - data={ - "username": "test-username", - "password": "test-password", - "name": "Yale Smart Alarm", - "area_id": "1", - }, - version=2, - ) - entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", - return_value=True, - ), - patch( - "homeassistant.components.yale_smart_alarm.async_setup_entry", - return_value=True, - ), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = load_config_entry[0] result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"lock_code_digits": 6}, - ) + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + return_value=load_config_entry[1], + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"lock_code_digits": 4}, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {"lock_code_digits": 6} + assert result["data"] == {"lock_code_digits": 4} + + assert entry.state == config_entries.ConfigEntryState.LOADED diff --git a/tests/components/yale_smart_alarm/test_coordinator.py b/tests/components/yale_smart_alarm/test_coordinator.py index 386e4ad72f7..8d30e8ad21a 100644 --- a/tests/components/yale_smart_alarm/test_coordinator.py +++ b/tests/components/yale_smart_alarm/test_coordinator.py @@ -48,7 +48,8 @@ async def test_coordinator_setup_errors( options=OPTIONS_CONFIG, entry_id="1", unique_id="username", - version=1, + version=2, + minor_version=2, ) config_entry.add_to_hass(hass) @@ -61,7 +62,7 @@ async def test_coordinator_setup_errors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert not state @@ -74,7 +75,7 @@ async def test_coordinator_setup_and_update_errors( client = load_config_entry[1] - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == AlarmControlPanelState.ARMED_AWAY client.reset_mock() @@ -82,7 +83,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -90,7 +91,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -98,7 +99,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -106,7 +107,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -116,7 +117,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == AlarmControlPanelState.ARMED_AWAY client.reset_mock() @@ -124,5 +125,5 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/yale_smart_alarm/test_init.py b/tests/components/yale_smart_alarm/test_init.py new file mode 100644 index 00000000000..c499320c29c --- /dev/null +++ b/tests/components/yale_smart_alarm/test_init.py @@ -0,0 +1,99 @@ +"""Test for Yale Smart Alarm component Init.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + get_client: Mock, +) -> None: + """Test setup entry.""" + entry = MockConfigEntry( + title=ENTRY_CONFIG["username"], + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=2, + minor_version=2, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + return_value=get_client, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry( + hass: HomeAssistant, + get_client: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrate entry unique id.""" + config = { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + options = {"lock_code_digits": 6, "code": "123456"} + entry = MockConfigEntry( + title=ENTRY_CONFIG["username"], + domain=DOMAIN, + source=SOURCE_USER, + data=config, + options=options, + entry_id="1", + unique_id="username", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + lock = entity_registry.async_get_or_create( + LOCK_DOMAIN, + DOMAIN, + "1111", + config_entry=entry, + has_entity_name=True, + original_name="Device1", + ) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + return_value=get_client, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.minor_version == 2 + assert entry.data == ENTRY_CONFIG + assert entry.options == OPTIONS_CONFIG + + lock_entity_id = entity_registry.async_get_entity_id(LOCK_DOMAIN, DOMAIN, "1111") + lock = entity_registry.async_get(lock_entity_id) + + assert lock.options == {"lock": {"default_code": "123456"}} diff --git a/tests/components/yale_smart_alarm/test_switch.py b/tests/components/yale_smart_alarm/test_switch.py index b189a3fd003..369f8f8f10c 100644 --- a/tests/components/yale_smart_alarm/test_switch.py +++ b/tests/components/yale_smart_alarm/test_switch.py @@ -8,8 +8,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from yalesmartalarmclient import YaleSmartAlarmData -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,3 +48,15 @@ async def test_switch( state = hass.states.get("switch.device1_autolock") assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.device1_autolock", + }, + blocking=True, + ) + + state = hass.states.get("switch.device1_autolock") + assert state.state == STATE_ON diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 518537262b2..56162d4d9d1 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -24,17 +24,17 @@ from yeelight.main import _MODEL_SPECS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, FLASH_LONG, FLASH_SHORT, SERVICE_TURN_OFF, SERVICE_TURN_ON, + ColorMode, LightEntityFeature, ) from homeassistant.components.yeelight.const import ( @@ -58,6 +58,7 @@ from homeassistant.components.yeelight.const import ( YEELIGHT_TEMPERATURE_TRANSACTION, ) from homeassistant.components.yeelight.light import ( + ATTR_KELVIN, ATTR_MINUTES, ATTR_MODE, EFFECT_CANDLE_FLICKER, @@ -106,7 +107,6 @@ from homeassistant.util.color import ( color_RGB_to_hs, color_RGB_to_xy, color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, ) from . import ( @@ -288,7 +288,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - # turn_on color_temp brightness = 100 - color_temp = 200 + color_temp = 5000 transition = 1 mocked_bulb.last_properties["power"] = "off" await hass.services.async_call( @@ -297,7 +297,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - { ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: brightness, - ATTR_COLOR_TEMP: color_temp, + ATTR_COLOR_TEMP_KELVIN: color_temp, ATTR_FLASH: FLASH_LONG, ATTR_EFFECT: EFFECT_STOP, ATTR_TRANSITION: transition, @@ -315,7 +315,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) mocked_bulb.async_set_color_temp.assert_called_once_with( - color_temperature_mired_to_kelvin(color_temp), + color_temp, duration=transition * 1000, light_type=LightType.Main, ) @@ -326,7 +326,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - # turn_on color_temp - flash short brightness = 100 - color_temp = 200 + color_temp = 5000 transition = 1 mocked_bulb.async_start_music.reset_mock() mocked_bulb.async_set_brightness.reset_mock() @@ -341,7 +341,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - { ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: brightness, - ATTR_COLOR_TEMP: color_temp, + ATTR_COLOR_TEMP_KELVIN: color_temp, ATTR_FLASH: FLASH_SHORT, ATTR_EFFECT: EFFECT_STOP, ATTR_TRANSITION: transition, @@ -359,7 +359,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) mocked_bulb.async_set_color_temp.assert_called_once_with( - color_temperature_mired_to_kelvin(color_temp), + color_temp, duration=transition * 1000, light_type=LightType.Main, ) @@ -690,7 +690,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: await hass.services.async_call( "light", SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) assert mocked_bulb.async_set_hsv.mock_calls == [] @@ -706,7 +706,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: await hass.services.async_call( "light", SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) assert mocked_bulb.async_set_hsv.mock_calls == [] @@ -719,7 +719,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: await hass.services.async_call( "light", SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) assert mocked_bulb.async_set_hsv.mock_calls == [] @@ -931,9 +931,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -962,9 +960,7 @@ async def test_device_types( "rgb_color": (255, 121, 0), "xy_color": (0.62, 0.368), "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -992,9 +988,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1028,9 +1022,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1065,9 +1057,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1102,9 +1092,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1138,9 +1126,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1173,12 +1159,8 @@ async def test_device_types( "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "effect": None, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1204,12 +1186,8 @@ async def test_device_types( "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "effect": None, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1217,17 +1195,15 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": nl_br, - "color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), + "color_temp_kelvin": model_specs["color_temp"]["min"], "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), "color_mode": "color_temp", "supported_color_modes": ["color_temp"], - "hs_color": (28.391, 65.659), - "rgb_color": (255, 167, 88), - "xy_color": (0.524, 0.388), + "hs_color": (28.395, 65.723), + "rgb_color": (255, 167, 87), + "xy_color": (0.525, 0.388), }, ) @@ -1245,12 +1221,8 @@ async def test_device_types( "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1279,12 +1251,8 @@ async def test_device_types( "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1292,17 +1260,15 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": nl_br, - "color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), + "color_temp_kelvin": model_specs["color_temp"]["min"], "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), "color_mode": "color_temp", "supported_color_modes": ["color_temp"], - "hs_color": (28.391, 65.659), - "rgb_color": (255, 167, 88), - "xy_color": (0.524, 0.388), + "hs_color": (28.395, 65.723), + "rgb_color": (255, 167, 87), + "xy_color": (0.525, 0.388), }, ) # Background light - color mode CT @@ -1315,16 +1281,18 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(6500) - ), + "max_color_temp_kelvin": 6500, "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, "color_temp_kelvin": bg_ct, "color_temp": bg_ct_kelvin, "color_mode": "color_temp", - "supported_color_modes": ["color_temp", "hs", "rgb"], + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ], "hs_color": (27.001, 19.243), "rgb_color": (255, 228, 206), "xy_color": (0.371, 0.349), @@ -1343,9 +1311,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(6500) - ), + "max_color_temp_kelvin": 6500, "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, @@ -1355,7 +1321,11 @@ async def test_device_types( "color_temp": None, "color_temp_kelvin": None, "color_mode": "hs", - "supported_color_modes": ["color_temp", "hs", "rgb"], + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ], }, name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", @@ -1371,9 +1341,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(6500) - ), + "max_color_temp_kelvin": 6500, "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, @@ -1383,7 +1351,11 @@ async def test_device_types( "color_temp": None, "color_temp_kelvin": None, "color_mode": "rgb", - "supported_color_modes": ["color_temp", "hs", "rgb"], + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ], }, name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr index dce546b4803..f4549e89c8c 100644 --- a/tests/components/youtube/snapshots/test_sensor.ambr +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -4,7 +4,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg', 'friendly_name': 'Google for Developers Latest upload', - 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=datetime.timezone.utc), + 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=TzInfo(UTC)), 'video_id': 'wysukDrMdqU', }), 'context': , diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a9f4c51d75d..1b280ea499a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch import warnings import pytest +import zhaquirks import zigpy from zigpy.application import ControllerApplication import zigpy.backups @@ -38,7 +39,7 @@ FIXTURE_GRP_NAME = "fixture group" COUNTER_NAMES = ["counter_1", "counter_2", "counter_3"] -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(scope="package", autouse=True) def globally_load_quirks(): """Load quirks automatically so that ZHA tests run deterministically in isolation. @@ -47,8 +48,6 @@ def globally_load_quirks(): run. """ - import zhaquirks # pylint: disable=import-outside-toplevel - zhaquirks.setup() diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index f46a06e84b8..08807f65d5d 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -113,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 4, diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 3761ba6eaa6..a3f70e92dcf 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5261,3 +5261,65 @@ async def test_cancel_secure_bootstrap_s2( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_subscribe_s2_inclusion( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: + """Test the subscribe_s2_inclusion websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_s2_inclusion", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + # Test receiving DSK request event + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test_dsk", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "validate dsk and enter pin", + "dsk": "test_dsk", + } + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_s2_inclusion", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test invalid config entry id + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_s2_inclusion", + ENTRY_ID: "INVALID", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 4c725c6dc29..21a6c0a8fae 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -7,10 +7,10 @@ from zwave_js_server.event import Event from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_SUPPORTED_COLOR_MODES, @@ -51,8 +51,8 @@ async def test_light( assert state assert state.state == STATE_OFF - assert state.attributes[ATTR_MIN_MIREDS] == 153 - assert state.attributes[ATTR_MAX_MIREDS] == 370 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] @@ -130,7 +130,7 @@ async def test_light( assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert state.attributes[ATTR_COLOR_TEMP] == 370 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2702 assert state.attributes[ATTR_RGB_COLOR] is not None # Test turning on with same brightness @@ -256,7 +256,7 @@ async def test_light( assert state.attributes[ATTR_COLOR_MODE] == "hs" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_RGB_COLOR] == (255, 76, 255) - assert state.attributes[ATTR_COLOR_TEMP] is None + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None client.async_send_command.reset_mock() @@ -293,7 +293,7 @@ async def test_light( await hass.services.async_call( "light", "turn_on", - {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP: 170}, + {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP_KELVIN: 5881}, blocking=True, ) @@ -358,14 +358,14 @@ async def test_light( assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert state.attributes[ATTR_COLOR_TEMP] == 170 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5881 assert ATTR_RGB_COLOR in state.attributes # Test turning on with same color temp await hass.services.async_call( "light", "turn_on", - {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP: 170}, + {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP_KELVIN: 5881}, blocking=True, ) @@ -379,7 +379,7 @@ async def test_light( "turn_on", { "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, - ATTR_COLOR_TEMP: 170, + ATTR_COLOR_TEMP_KELVIN: 5881, ATTR_TRANSITION: 35, }, blocking=True, diff --git a/tests/conftest.py b/tests/conftest.py index c46ed0407e5..2cefe72f414 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1899,7 +1899,7 @@ def service_calls(hass: HomeAssistant) -> Generator[list[ServiceCall]]: return_response: bool = False, ) -> ServiceResponse: calls.append( - ServiceCall(domain, service, service_data, context, return_response) + ServiceCall(hass, domain, service, service_data, context, return_response) ) try: return await _original_async_call( diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index e70bee104c9..b9259596c65 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -12,13 +12,12 @@ from script.hassfest.requirements import validate_requirements_format def integration(): """Fixture for hassfest integration model.""" return Integration( - path=Path("homeassistant/components/test"), + path=Path("homeassistant/components/test").absolute(), _config=Config( root=Path(".").absolute(), specific_integrations=None, action="validate", requirements=True, - core_integrations_path=Path("homeassistant/components"), ), _manifest={ "domain": "test", diff --git a/tests/hassfest/test_version.py b/tests/hassfest/test_version.py index 30677356101..20c3d93bda5 100644 --- a/tests/hassfest/test_version.py +++ b/tests/hassfest/test_version.py @@ -16,13 +16,12 @@ from script.hassfest.model import Config, Integration def integration(): """Fixture for hassfest integration model.""" integration = Integration( - "", + Path(), _config=Config( root=Path(".").absolute(), specific_integrations=None, action="validate", requirements=True, - core_integrations_path=Path("homeassistant/components"), ), ) integration._manifest = { diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf441f70fd..dc579ab6e8d 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta -from enum import IntFlag import logging import threading from typing import Any @@ -2486,31 +2485,6 @@ async def test_cached_entity_property_override(hass: HomeAssistant) -> None: return "🤡" -async def test_entity_report_deprecated_supported_features_values( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test reporting deprecated supported feature values only happens once.""" - ent = entity.Entity() - - class MockEntityFeatures(IntFlag): - VALUE1 = 1 - VALUE2 = 2 - - ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) - assert ( - "is using deprecated supported features values which will be removed" - in caplog.text - ) - assert "MockEntityFeatures.VALUE2" in caplog.text - - caplog.clear() - ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) - assert ( - "is using deprecated supported features values which will be removed" - not in caplog.text - ) - - async def test_remove_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 9723b91eb9a..940bd3e37fd 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -189,13 +189,14 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non ] ) - call_1 = ServiceCall("test", "service", data={"entity_id": ENTITY_MATCH_ALL}) + call_1 = ServiceCall(hass, "test", "service", data={"entity_id": ENTITY_MATCH_ALL}) assert sorted( ent.entity_id for ent in (await component.async_extract_from_service(call_1)) ) == ["test_domain.test_1", "test_domain.test_3"] call_2 = ServiceCall( + hass, "test", "service", data={"entity_id": ["test_domain.test_3", "test_domain.test_4"]}, @@ -256,17 +257,18 @@ async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) - ) assert ( - await component.async_extract_from_service(ServiceCall("test", "service")) == [] + await component.async_extract_from_service(ServiceCall(hass, "test", "service")) + == [] ) assert ( await component.async_extract_from_service( - ServiceCall("test", "service", {"entity_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "test", "service", {"entity_id": ENTITY_MATCH_NONE}) ) == [] ) assert ( await component.async_extract_from_service( - ServiceCall("test", "service", {"area_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "test", "service", {"area_id": ENTITY_MATCH_NONE}) ) == [] ) @@ -283,6 +285,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ) call = ServiceCall( + hass, "test", "service", {"entity_id": ["test_domain.test_2", "test_domain.non_exist"]}, @@ -299,7 +302,7 @@ async def test_extract_from_service_no_group_expand(hass: HomeAssistant) -> None await component.async_setup({}) await component.async_add_entities([MockEntity(entity_id="group.test_group")]) - call = ServiceCall("test", "service", {"entity_id": ["group.test_group"]}) + call = ServiceCall(hass, "test", "service", {"entity_id": ["group.test_group"]}) extracted = await component.async_extract_from_service(call, expand_group=False) assert len(extracted) == 1 @@ -465,7 +468,7 @@ async def test_extract_all_omit_entity_id( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) - call = ServiceCall("test", "service") + call = ServiceCall(hass, "test", "service") assert ( sorted( @@ -485,7 +488,7 @@ async def test_extract_all_use_match_all( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) - call = ServiceCall("test", "service", {"entity_id": "all"}) + call = ServiceCall(hass, "test", "service", {"entity_id": "all"}) assert sorted( ent.entity_id for ent in await component.async_extract_from_service(call) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e63cb69909c..6d03e09cdf7 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -642,11 +642,11 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: order=None, ) - call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) + call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) assert {"light.bowl"} == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "group.test"}) + call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "group.test"}) assert {"light.ceiling", "light.kitchen"} == await service.async_extract_entity_ids( hass, call @@ -659,7 +659,7 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: assert ( await service.async_extract_entity_ids( hass, - ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: ENTITY_MATCH_NONE}), + ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: ENTITY_MATCH_NONE}), ) == set() ) @@ -669,20 +669,22 @@ async def test_extract_entity_ids_from_area( hass: HomeAssistant, floor_area_mock ) -> None: """Test extract_entity_ids method with areas.""" - call = ServiceCall("light", "turn_on", {"area_id": "own-area"}) + call = ServiceCall(hass, "light", "turn_on", {"area_id": "own-area"}) assert { "light.in_own_area", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"area_id": "test-area"}) + call = ServiceCall(hass, "light", "turn_on", {"area_id": "test-area"}) assert { "light.in_area", "light.assigned_to_area", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"area_id": ["test-area", "diff-area"]}) + call = ServiceCall( + hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"]} + ) assert { "light.in_area", @@ -692,7 +694,7 @@ async def test_extract_entity_ids_from_area( assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"area_id": ENTITY_MATCH_NONE}) + hass, ServiceCall(hass, "light", "turn_on", {"area_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -703,13 +705,13 @@ async def test_extract_entity_ids_from_devices( ) -> None: """Test extract_entity_ids method with devices.""" assert await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"device_id": "device-no-area-id"}) + hass, ServiceCall(hass, "light", "turn_on", {"device_id": "device-no-area-id"}) ) == { "light.no_area", } assert await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"device_id": "device-area-a-id"}) + hass, ServiceCall(hass, "light", "turn_on", {"device_id": "device-area-a-id"}) ) == { "light.in_area_a", "light.in_area_b", @@ -717,7 +719,8 @@ async def test_extract_entity_ids_from_devices( assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"device_id": "non-existing-id"}) + hass, + ServiceCall(hass, "light", "turn_on", {"device_id": "non-existing-id"}), ) == set() ) @@ -726,14 +729,16 @@ async def test_extract_entity_ids_from_devices( @pytest.mark.usefixtures("floor_area_mock") async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: """Test extract_entity_ids method with floors.""" - call = ServiceCall("light", "turn_on", {"floor_id": "test-floor"}) + call = ServiceCall(hass, "light", "turn_on", {"floor_id": "test-floor"}) assert { "light.in_area", "light.assigned_to_area", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"floor_id": ["test-floor", "floor-a"]}) + call = ServiceCall( + hass, "light", "turn_on", {"floor_id": ["test-floor", "floor-a"]} + ) assert { "light.in_area", @@ -743,7 +748,7 @@ async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"floor_id": ENTITY_MATCH_NONE}) + hass, ServiceCall(hass, "light", "turn_on", {"floor_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -752,13 +757,13 @@ async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("label_mock") async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: """Test extract_entity_ids method with labels.""" - call = ServiceCall("light", "turn_on", {"label_id": "my-label"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "my-label"}) assert { "light.with_my_label", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"label_id": "label1"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "label1"}) assert { "light.with_label1_from_device", @@ -767,14 +772,14 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: "light.with_label1_and_label2_from_device", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"label_id": ["label2"]}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": ["label2"]}) assert { "light.with_labels_from_device", "light.with_label1_and_label2_from_device", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"label_id": ["label_area"]}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": ["label_area"]}) assert { "light.with_labels_from_device", @@ -782,7 +787,7 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"label_id": ENTITY_MATCH_NONE}) + hass, ServiceCall(hass, "light", "turn_on", {"label_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -1281,7 +1286,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A], ) @@ -1305,7 +1310,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - mock_entities, HassJob(test_service_mock), ServiceCall( - "test_domain", "test_service", {"entity_id": "light.living_room"} + hass, "test_domain", "test_service", {"entity_id": "light.living_room"} ), required_features=[SUPPORT_A], ) @@ -1321,7 +1326,7 @@ async def test_call_with_both_required_features( hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A | SUPPORT_B], ) @@ -1340,7 +1345,7 @@ async def test_call_with_one_of_required_features( hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A, SUPPORT_C], ) @@ -1361,7 +1366,9 @@ async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), + ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen"} + ), ) assert test_service_mock.call_count == 1 @@ -1374,6 +1381,7 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None: mock_entities, "sync_method", ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen", "area_id": "abcd"}, @@ -1392,6 +1400,7 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: {}, Mock(), ServiceCall( + hass, "test_domain", "test_service", context=Context(user_id="non-existing"), @@ -1419,6 +1428,7 @@ async def test_call_context_target_all( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL}, @@ -1447,6 +1457,7 @@ async def test_call_context_target_specific( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen"}, @@ -1474,6 +1485,7 @@ async def test_call_context_target_specific_no_auth( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen"}, @@ -1494,7 +1506,7 @@ async def test_call_no_context_target_all( mock_entities, Mock(), ServiceCall( - "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} + hass, "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} ), ) @@ -1513,6 +1525,7 @@ async def test_call_no_context_target_specific( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", {"entity_id": ["light.kitchen", "light.non-existing"]}, @@ -1534,7 +1547,7 @@ async def test_call_with_match_all( hass, mock_entities, Mock(), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), ) assert len(mock_handle_entity_call.mock_calls) == 4 @@ -1551,7 +1564,7 @@ async def test_call_with_omit_entity_id( hass, mock_entities, Mock(), - ServiceCall("test_domain", "test_service"), + ServiceCall(hass, "test_domain", "test_service"), ) assert len(mock_handle_entity_call.mock_calls) == 0 @@ -1797,7 +1810,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non MockEntity(name="test_4", entity_id="test_domain.test_4", available=False), ] - call_1 = ServiceCall("test", "service", data={"entity_id": ENTITY_MATCH_ALL}) + call_1 = ServiceCall(hass, "test", "service", data={"entity_id": ENTITY_MATCH_ALL}) assert [ ent.entity_id @@ -1805,6 +1818,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non ] == ["test_domain.test_1", "test_domain.test_3"] call_2 = ServiceCall( + hass, "test", "service", data={"entity_id": ["test_domain.test_3", "test_domain.test_4"]}, @@ -1820,6 +1834,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non hass, entities, ServiceCall( + hass, "test", "service", data={"entity_id": ENTITY_MATCH_NONE}, @@ -1835,7 +1850,7 @@ async def test_extract_from_service_empty_if_no_entity_id(hass: HomeAssistant) - MockEntity(name="test_1", entity_id="test_domain.test_1"), MockEntity(name="test_2", entity_id="test_domain.test_2"), ] - call = ServiceCall("test", "service") + call = ServiceCall(hass, "test", "service") assert [ ent.entity_id @@ -1853,6 +1868,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ] call = ServiceCall( + hass, "test", "service", {"entity_id": ["test_domain.test_2", "test_domain.non_exist"]}, @@ -1874,12 +1890,14 @@ async def test_extract_from_service_area_id( MockEntity(name="diff_area", entity_id="light.diff_area"), ] - call = ServiceCall("light", "turn_on", {"area_id": "test-area"}) + call = ServiceCall(hass, "light", "turn_on", {"area_id": "test-area"}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.in_area" - call = ServiceCall("light", "turn_on", {"area_id": ["test-area", "diff-area"]}) + call = ServiceCall( + hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"]} + ) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 2 assert sorted(ent.entity_id for ent in extracted) == [ @@ -1888,6 +1906,7 @@ async def test_extract_from_service_area_id( ] call = ServiceCall( + hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"], "device_id": "device-no-area-id"}, @@ -1912,17 +1931,17 @@ async def test_extract_from_service_label_id(hass: HomeAssistant) -> None: ), ] - call = ServiceCall("light", "turn_on", {"label_id": "label_area"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "label_area"}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.with_labels_from_device" - call = ServiceCall("light", "turn_on", {"label_id": "my-label"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "my-label"}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.with_my_label" - call = ServiceCall("light", "turn_on", {"label_id": ["my-label", "label1"]}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": ["my-label", "label1"]}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 2 assert sorted(ent.entity_id for ent in extracted) == [ @@ -1931,6 +1950,7 @@ async def test_extract_from_service_label_id(hass: HomeAssistant) -> None: ] call = ServiceCall( + hass, "light", "turn_on", {"label_id": ["my-label", "label1"], "device_id": "device-no-labels"}, @@ -1949,6 +1969,7 @@ async def test_entity_service_call_warn_referenced( ) -> None: """Test we only warn for referenced entities in entity_service_call.""" call = ServiceCall( + hass, "light", "turn_on", { @@ -1972,6 +1993,7 @@ async def test_async_extract_entities_warn_referenced( ) -> None: """Test we only warn for referenced entities in async_extract_entities.""" call = ServiceCall( + hass, "light", "turn_on", { @@ -1997,6 +2019,7 @@ async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) call = ServiceCall( + hass, "homeassistant", "reload_config_entry", { @@ -2042,17 +2065,33 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloader = service.ReloadServiceHelper(reload_service_handler, reload_targets) tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, target1) # while the first task is reloaded, note that target1 can't be deduplicated # because it's already being reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered( @@ -2063,13 +2102,21 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, all) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service(ServiceCall(hass, "test", "test")), ] await asyncio.gather(*tasks) assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) @@ -2078,13 +2125,21 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (all) - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service(ServiceCall(hass, "test", "test")), # These reload tasks will be deduplicated to (target1, target2, target3, target4) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) @@ -2093,21 +2148,45 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, target1) # while the first task is reloaded, note that target1 can't be deduplicated # because it's already being reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered( @@ -2118,14 +2197,22 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, all) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test")), - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service(ServiceCall(hass, "test", "test")), + reloader.execute_service(ServiceCall(hass, "test", "test")), ] await asyncio.gather(*tasks) assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) @@ -2134,17 +2221,33 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (all) - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service(ServiceCall(hass, "test", "test")), # These reload tasks will be deduplicated to (target1, target2, target3, target4) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index 16b5b8b652b..2c4b95302fc 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -93,10 +93,9 @@ async def test_container_installationtype(hass: HomeAssistant) -> None: assert info["installation_type"] == "Unsupported Third Party Container" -async def test_getuser_keyerror(hass: HomeAssistant) -> None: - """Test getuser keyerror.""" - with patch( - "homeassistant.helpers.system_info.cached_get_user", side_effect=KeyError - ): +@pytest.mark.parametrize("error", [KeyError, OSError]) +async def test_getuser_oserror(hass: HomeAssistant, error: Exception) -> None: + """Test getuser oserror.""" + with patch("homeassistant.helpers.system_info.cached_get_user", side_effect=error): info = await async_get_system_info(hass) assert info["user"] is None diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index 51e56f4874e..08b532677f4 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 44a05c0540e..bce5eca4292 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -19,7 +19,29 @@ from .common import get_test_config_dir ( None, '{"path": "test"}', - backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), + None, + ), + ( + None, + '{"path": "test", "password": "psw", "remove_after_restore": false, "restore_database": false, "restore_homeassistant": true}', + backup_restore.RestoreBackupFileContent( + backup_file_path=Path("test"), + password="psw", + remove_after_restore=False, + restore_database=False, + restore_homeassistant=True, + ), + ), + ( + None, + '{"path": "test", "password": null, "remove_after_restore": true, "restore_database": true, "restore_homeassistant": false}', + backup_restore.RestoreBackupFileContent( + backup_file_path=Path("test"), + password=None, + remove_after_restore=True, + restore_database=True, + restore_homeassistant=False, + ), ), ], ) @@ -49,7 +71,11 @@ def test_restoring_backup_that_does_not_exist() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch("pathlib.Path.read_text", side_effect=FileNotFoundError), @@ -78,7 +104,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch("pathlib.Path.exists", return_value=True), @@ -102,7 +132,11 @@ def test_aborting_for_older_versions() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch("securetar.SecureTarFile"), @@ -117,14 +151,78 @@ def test_aborting_for_older_versions() -> None: assert backup_restore.restore_backup(config_dir) is True -def test_removal_of_current_configuration_when_restoring() -> None: +@pytest.mark.parametrize( + ( + "restore_backup_content", + "expected_removed_files", + "expected_removed_directories", + "expected_copied_files", + "expected_copied_trees", + ), + [ + ( + backup_restore.RestoreBackupFileContent( + backup_file_path=None, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, + ), + ( + ".HA_RESTORE", + ".HA_VERSION", + "home-assistant_v2.db", + "home-assistant_v2.db-wal", + ), + ("tmp_backups", "www"), + (), + ("data",), + ), + ( + backup_restore.RestoreBackupFileContent( + backup_file_path=None, + password=None, + restore_database=False, + remove_after_restore=False, + restore_homeassistant=True, + ), + (".HA_RESTORE", ".HA_VERSION"), + ("tmp_backups", "www"), + (), + ("data",), + ), + ( + backup_restore.RestoreBackupFileContent( + backup_file_path=None, + password=None, + restore_database=True, + remove_after_restore=False, + restore_homeassistant=False, + ), + ("home-assistant_v2.db", "home-assistant_v2.db-wal"), + (), + ("home-assistant_v2.db", "home-assistant_v2.db-wal"), + (), + ), + ], +) +def test_removal_of_current_configuration_when_restoring( + restore_backup_content: backup_restore.RestoreBackupFileContent, + expected_removed_files: tuple[str, ...], + expected_removed_directories: tuple[str, ...], + expected_copied_files: tuple[str, ...], + expected_copied_trees: tuple[str, ...], +) -> None: """Test that we are removing the current configuration directory.""" config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") + restore_backup_content.backup_file_path = Path(config_dir, "backups", "test.tar") mock_config_dir = [ {"path": Path(config_dir, ".HA_RESTORE"), "is_file": True}, {"path": Path(config_dir, ".HA_VERSION"), "is_file": True}, + {"path": Path(config_dir, "home-assistant_v2.db"), "is_file": True}, + {"path": Path(config_dir, "home-assistant_v2.db-wal"), "is_file": True}, {"path": Path(config_dir, "backups"), "is_file": False}, + {"path": Path(config_dir, "tmp_backups"), "is_file": False}, {"path": Path(config_dir, "www"), "is_file": False}, ] @@ -140,12 +238,10 @@ def test_removal_of_current_configuration_when_restoring() -> None: with ( mock.patch( "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path - ), + return_value=restore_backup_content, ), mock.patch("securetar.SecureTarFile"), - mock.patch("homeassistant.backup_restore.TemporaryDirectory"), + mock.patch("homeassistant.backup_restore.TemporaryDirectory") as temp_dir_mock, mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), mock.patch("pathlib.Path.read_text", _patched_path_read_text), mock.patch("pathlib.Path.is_file", _patched_path_is_file), @@ -154,17 +250,33 @@ def test_removal_of_current_configuration_when_restoring() -> None: "pathlib.Path.iterdir", return_value=[x["path"] for x in mock_config_dir], ), - mock.patch("pathlib.Path.unlink") as unlink_mock, - mock.patch("shutil.rmtree") as rmtreemock, + mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, + mock.patch("shutil.copy") as copy_mock, + mock.patch("shutil.copytree") as copytree_mock, + mock.patch("shutil.rmtree") as rmtree_mock, ): - assert backup_restore.restore_backup(config_dir) is True - assert unlink_mock.call_count == 2 - assert ( - rmtreemock.call_count == 1 - ) # We have 2 directories in the config directory, but backups is kept + temp_dir_mock.return_value.__enter__.return_value = "tmp" - removed_directories = {Path(call.args[0]) for call in rmtreemock.mock_calls} - assert removed_directories == {Path(config_dir, "www")} + assert backup_restore.restore_backup(config_dir) is True + + tmp_ha = Path("tmp", "homeassistant") + assert copy_mock.call_count == len(expected_copied_files) + copied_files = {Path(call.args[0]) for call in copy_mock.mock_calls} + assert copied_files == {Path(tmp_ha, "data", f) for f in expected_copied_files} + + assert copytree_mock.call_count == len(expected_copied_trees) + copied_trees = {Path(call.args[0]) for call in copytree_mock.mock_calls} + assert copied_trees == {Path(tmp_ha, t) for t in expected_copied_trees} + + assert unlink_mock.call_count == len(expected_removed_files) + removed_files = {Path(call.args[0]) for call in unlink_mock.mock_calls} + assert removed_files == {Path(config_dir, f) for f in expected_removed_files} + + assert rmtree_mock.call_count == len(expected_removed_directories) + removed_directories = {Path(call.args[0]) for call in rmtree_mock.mock_calls} + assert removed_directories == { + Path(config_dir, d) for d in expected_removed_directories + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -177,8 +289,8 @@ def test_extracting_the_contents_of_a_backup_file() -> None: getmembers_mock = mock.MagicMock( return_value=[ + tarfile.TarInfo(name="../data/test"), tarfile.TarInfo(name="data"), - tarfile.TarInfo(name="data/../test"), tarfile.TarInfo(name="data/.HA_VERSION"), tarfile.TarInfo(name="data/.storage"), tarfile.TarInfo(name="data/www"), @@ -190,7 +302,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch( @@ -205,11 +321,59 @@ def test_extracting_the_contents_of_a_backup_file() -> None: mock.patch("pathlib.Path.read_text", _patched_path_read_text), mock.patch("pathlib.Path.is_file", return_value=False), mock.patch("pathlib.Path.iterdir", return_value=[]), + mock.patch("shutil.copytree"), ): assert backup_restore.restore_backup(config_dir) is True - assert getmembers_mock.call_count == 1 assert extractall_mock.call_count == 2 assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] - } == {".HA_VERSION", ".storage", "www"} + } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + + +@pytest.mark.parametrize( + ("remove_after_restore", "unlink_calls"), [(True, 1), (False, 0)] +) +def test_remove_backup_file_after_restore( + remove_after_restore: bool, unlink_calls: int +) -> None: + """Test removing a backup file after restore.""" + config_dir = Path(get_test_config_dir()) + backup_file_path = Path(config_dir, "backups", "test.tar") + + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path, + password=None, + remove_after_restore=remove_after_restore, + restore_database=True, + restore_homeassistant=True, + ), + ), + mock.patch("homeassistant.backup_restore._extract_backup"), + mock.patch("pathlib.Path.unlink", autospec=True) as mock_unlink, + ): + assert backup_restore.restore_backup(config_dir) is True + assert mock_unlink.call_count == unlink_calls + for call in mock_unlink.mock_calls: + assert call.args[0] == backup_file_path + + +@pytest.mark.parametrize( + ("password", "expected"), + [ + ("test", b"\xf0\x9b\xb9\x1f\xdc,\xff\xd5x\xd6\xd6\x8fz\x19.\x0f"), + ("lorem ipsum...", b"#\xe0\xfc\xe0\xdb?_\x1f,$\rQ\xf4\xf5\xd8\xfb"), + ], +) +def test_pw_to_key(password: str | None, expected: bytes | None) -> None: + """Test password to key conversion.""" + assert backup_restore.password_to_key(password) == expected + + +def test_pw_to_key_none() -> None: + """Test password to key conversion.""" + with pytest.raises(AttributeError): + backup_restore.password_to_key(None) diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index dc2b096f595..dd23d4e9709 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -429,6 +429,12 @@ async def test_protect_loop_load_verify_locations( context.load_verify_locations("/dev/null") assert "Detected blocking call to load_verify_locations" in caplog.text + # ignore with only cadata + caplog.clear() + with pytest.raises(ssl.SSLError): + context.load_verify_locations(cadata="xxx") + assert "Detected blocking call to load_verify_locations" not in caplog.text + async def test_protect_loop_load_cert_chain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index aba85a35349..1ad152e8e42 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator +from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import logging import re @@ -905,7 +906,7 @@ async def test_entries_excludes_ignore_and_disabled( async def test_saving_and_loading( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any] ) -> None: """Test that we're saving and loading correctly.""" mock_integration( @@ -922,7 +923,17 @@ async def test_saving_and_loading( async def async_step_user(self, user_input=None): """Test user step.""" await self.async_set_unique_id("unique") - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) + subentries = [ + config_entries.ConfigSubentryData( + data={"foo": "bar"}, title="subentry 1" + ), + config_entries.ConfigSubentryData( + data={"sun": "moon"}, title="subentry 2", unique_id="very_unique" + ), + ] + return self.async_create_entry( + title="Test Title", data={"token": "abcd"}, subentries=subentries + ) with mock_config_flow("test", TestFlow): await hass.config_entries.flow.async_init( @@ -971,6 +982,98 @@ async def test_saving_and_loading( # To execute the save await hass.async_block_till_done() + stored_data = hass_storage["core.config_entries"] + assert stored_data == { + "data": { + "entries": [ + { + "created_at": ANY, + "data": { + "token": "abcd", + }, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": True, + "pref_disable_polling": True, + "source": "user", + "subentries": [ + { + "data": {"foo": "bar"}, + "subentry_id": ANY, + "title": "subentry 1", + "unique_id": None, + }, + { + "data": {"sun": "moon"}, + "subentry_id": ANY, + "title": "subentry 2", + "unique_id": "very_unique", + }, + ], + "title": "Test Title", + "unique_id": "unique", + "version": 5, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": "blah", "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": ["a", "b"], "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + ], + }, + "key": "core.config_entries", + "minor_version": 5, + "version": 1, + } + # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) await manager.async_initialize() @@ -983,6 +1086,25 @@ async def test_saving_and_loading( ): assert orig.as_dict() == loaded.as_dict() + hass.config_entries.async_update_entry( + entry_1, + pref_disable_polling=False, + pref_disable_new_entities=False, + ) + + # To trigger the call_later + freezer.tick(1.0) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + + # Assert no data is lost when storing again + expected_stored_data = stored_data + expected_stored_data["data"]["entries"][0]["modified_at"] = ANY + expected_stored_data["data"]["entries"][0]["pref_disable_new_entities"] = False + expected_stored_data["data"]["entries"][0]["pref_disable_polling"] = False + assert hass_storage["core.config_entries"] == expected_stored_data | {} + @freeze_time("2024-02-14 12:00:00") async def test_as_dict(snapshot: SnapshotAssertion) -> None: @@ -1416,6 +1538,42 @@ async def test_update_entry_options_and_trigger_listener( assert len(update_listener_calls) == 1 +async def test_update_subentry_and_trigger_listener( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can update subentry and trigger listener.""" + entry = MockConfigEntry(domain="test", options={"first": True}) + entry.add_to_manager(manager) + update_listener_calls = [] + + subentry = config_entries.ConfigSubentry( + data={"test": "test"}, unique_id="test", title="Mock title" + ) + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == expected_subentries + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + + expected_subentries = {subentry.subentry_id: subentry} + assert manager.async_add_subentry(entry, subentry) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 1 + + expected_subentries = {} + assert manager.async_remove_subentry(entry, subentry.subentry_id) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 2 + + async def test_setup_raise_not_ready( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1742,17 +1900,453 @@ async def test_entry_options_unknown_config_entry( mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) - class TestFlow: + with pytest.raises(config_entries.UnknownEntry): + await manager.options.async_create_flow( + "blah", context={"source": "test"}, data=None + ) + + +async def test_create_entry_subentries( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test a config entry being created with subentries.""" + + subentrydata = config_entries.ConfigSubentryData( + data={"test": "test"}, + title="Mock title", + unique_id="test", + ) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + data={"data": "data", "subentry": subentrydata}, + ) + ) + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Test import step creating entry, with subentry.""" + return self.async_create_entry( + title="title", + data={"example": user_input["data"]}, + subentries=[user_input["subentry"]], + ) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + assert await async_setup_component(hass, "comp", {}) + + await hass.async_block_till_done() + + assert len(async_setup_entry.mock_calls) == 1 + + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + assert entries[0].supported_subentries == () + assert entries[0].data == {"example": "data"} + assert len(entries[0].subentries) == 1 + subentry_id = list(entries[0].subentries)[0] + subentry = config_entries.ConfigSubentry( + data=subentrydata["data"], + subentry_id=subentry_id, + title=subentrydata["title"], + unique_id="test", + ) + assert entries[0].subentries == {subentry_id: subentry} + + +async def test_entry_subentry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can add a subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): """Test flow.""" @staticmethod @callback - def async_get_options_flow(config_entry): - """Test options flow.""" + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", + }, + ) + + assert entry.data == {"first": True} + assert entry.options == {} + subentry_id = list(entry.subentries)[0] + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + title="Mock title", + unique_id="test", + ) + } + assert entry.supported_subentries == ("test",) + + +async def test_entry_subentry_non_string( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test adding an invalid subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + with pytest.raises(HomeAssistantError): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": 123, + }, + ) + + +@pytest.mark.parametrize("context", [None, {}, {"bla": "bleh"}]) +async def test_entry_subentry_no_context( + hass: HomeAssistant, manager: config_entries.ConfigEntries, context: dict | None +) -> None: + """Test starting a subentry flow without "source" in context.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with mock_config_flow("test", TestFlow), pytest.raises(KeyError): + await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context=context, data=None + ) + + +@pytest.mark.parametrize( + ("unique_id", "expected_result"), + [(None, does_not_raise()), ("test", pytest.raises(HomeAssistantError))], +) +async def test_entry_subentry_duplicate( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unique_id: str | None, + expected_result: AbstractContextManager, +) -> None: + """Test adding a duplicated subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry( + domain="test", + data={"first": True}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="blabla", + title="Mock title", + unique_id=unique_id, + ) + ], + ) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + with expected_result: + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": unique_id, + }, + ) + + +async def test_entry_subentry_abort( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + assert await manager.subentries.async_finish_flow( + flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"} + ) + + +async def test_entry_subentry_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for an unknown config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) with pytest.raises(config_entries.UnknownEntry): - await manager.options.async_create_flow( - "blah", context={"source": "test"}, data=None + await manager.subentries.async_create_flow( + ("blah", "blah"), context={"source": "test"}, data=None + ) + + +async def test_entry_subentry_deleted_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to finish a subentry flow for a deleted config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + await hass.config_entries.async_remove(entry.entry_id) + + with pytest.raises(config_entries.UnknownEntry): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", + }, + ) + + +async def test_entry_subentry_unsupported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry, subentry_type: str): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + @classmethod + @callback + def async_supported_subentries( + cls, config_entry: ConfigEntry + ) -> tuple[str, ...]: + return ("test",) + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + ( + entry.entry_id, + "unknown", + ), + context={"source": "test"}, + data=None, + ) + + +async def test_entry_subentry_unsupported_subentry_type( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None ) @@ -3911,21 +4505,20 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry) is False - for change in ( - {"data": {"second": True, "third": 456}}, - {"data": {"second": True}}, - {"minor_version": 2}, - {"options": {"hello": True}}, - {"pref_disable_new_entities": True}, - {"pref_disable_polling": True}, - {"title": "sometitle"}, - {"unique_id": "abcd1234"}, - {"version": 2}, + for change, expected_value in ( + ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}), + ({"data": {"second": True}}, {"second": True}), + ({"minor_version": 2}, 2), + ({"options": {"hello": True}}, {"hello": True}), + ({"pref_disable_new_entities": True}, True), + ({"pref_disable_polling": True}, True), + ({"title": "sometitle"}, "sometitle"), + ({"unique_id": "abcd1234"}, "abcd1234"), + ({"version": 2}, 2), ): assert manager.async_update_entry(entry, **change) is True key = next(iter(change)) - value = next(iter(change.values())) - assert getattr(entry, key) == value + assert getattr(entry, key) == expected_value assert manager.async_update_entry(entry, **change) is False assert manager.async_entry_for_domain_unique_id("test", "abc123") is None @@ -5459,6 +6052,7 @@ async def test_unhashable_unique_id_fails( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -5494,6 +6088,7 @@ async def test_unhashable_unique_id_fails_on_update( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5524,6 +6119,7 @@ async def test_string_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5566,6 +6162,7 @@ async def test_hashable_unique_id( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -5600,6 +6197,7 @@ async def test_no_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=None, version=1, @@ -6524,6 +7122,7 @@ async def test_migration_from_1_2( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "import", + "subentries": {}, "title": "Sun", "unique_id": None, "version": 1, diff --git a/tests/test_core.py b/tests/test_core.py index 0100c35055e..60b907d57ca 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1562,10 +1562,10 @@ async def test_statemachine_avoids_updating_attributes(hass: HomeAssistant) -> N def test_service_call_repr() -> None: """Test ServiceCall repr.""" - call = ha.ServiceCall("homeassistant", "start") + call = ha.ServiceCall(None, "homeassistant", "start") assert str(call) == f"" - call2 = ha.ServiceCall("homeassistant", "start", {"fast": "yes"}) + call2 = ha.ServiceCall(None, "homeassistant", "start", {"fast": "yes"}) assert ( str(call2) == f"" diff --git a/tests/test_core_config.py b/tests/test_core_config.py index cd77e3608dd..dae50bae097 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -1080,3 +1080,27 @@ async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: ), ): await hass.config.set_time_zone("America/New_York") + + +async def test_core_config_schema_imperial_unit( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test core config schema.""" + await async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Home", + "unit_system": "imperial", + "time_zone": "America/New_York", + "currency": "USD", + "country": "US", + "language": "en", + "radius": 150, + }, + ) + + issue = issue_registry.async_get_issue("homeassistant", "imperial_unit_system") + assert issue diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 4d1eda3d8de..4be32b2851e 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -441,6 +441,8 @@ _CONVERTED_VALUE: dict[ (5, UnitOfElectricPotential.MICROVOLT, 5e-6, UnitOfElectricPotential.VOLT), ], EnergyConverter: [ + (10, UnitOfEnergy.MILLIWATT_HOUR, 0.00001, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 10000, UnitOfEnergy.MILLIWATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.00001, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.00000001, UnitOfEnergy.GIGA_WATT_HOUR), diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index b2c604acbcf..ddefe92de42 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -24,6 +24,8 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import HomeAssistantError from homeassistant.util.unit_system import ( # pylint: disable=hass-deprecated-import _CONF_UNIT_SYSTEM_IMPERIAL, @@ -877,3 +879,35 @@ def test_imperial_converted_units(device_class: SensorDeviceClass) -> None: assert (device_class, unit) not in unit_system._conversions continue assert (device_class, unit) in unit_system._conversions + + +async def test_imperial_deprecated_log_warning( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated imperial unit system logs warning.""" + await async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Home", + "unit_system": "imperial", + "time_zone": "America/New_York", + "currency": "USD", + "country": "US", + "language": "en", + "radius": 150, + }, + ) + + assert hass.config.latitude == 60 + assert hass.config.longitude == 50 + assert hass.config.elevation == 25 + assert hass.config.location_name == "Home" + assert hass.config.units is US_CUSTOMARY_SYSTEM + assert hass.config.time_zone == "America/New_York" + assert hass.config.currency == "USD" + assert hass.config.country == "US" + assert hass.config.language == "en" + assert hass.config.radius == 150